JavaScript & TypeScript
The most common way a six-year candidate gets rejected is knowing React deeply and JavaScript shallowly. These are the questions that expose it. Every answer here has a shallow version that every candidate gives and a deep version that almost nobody does — the deep half is what is written out.
setTimeout sit?JavaScript runs one call stack. When the stack empties, the runtime drains the microtask queue completely — resolved promise callbacks, queueMicrotask, MutationObserver — and only then takes one macrotask: a timer, an I/O callback, a UI event. Then it drains microtasks again. So microtasks always run before the next macrotask, and a microtask that schedules another microtask can starve the loop entirely.
In Node the macrotask side is split into phases that run in a fixed order:
timers // setTimeout / setInterval callbacks
pending // some system-level callbacks
poll // I/O — where the loop actually waits
check // setImmediate
close // 'close' events (socket.on('close'))
// between EVERY phase transition: drain nextTick queue, then microtasksconsole.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
process.nextTick(() => console.log('4'))
queueMicrotask(() => console.log('5'))
console.log('6')
// 1 6 4 3 5 2
// sync first (1,6), then nextTick (4) — its own queue, ahead of
// promises — then microtasks in scheduling order (3,5), then timers (2)"Promises go to the callback queue and setTimeout goes to the callback queue, and the event loop picks them in order." That is the tutorial answer and it is wrong — there are two queues with different priorities, and the whole question exists to find out whether you know that.
Two extras that make you sound like you have debugged this rather than read it: setTimeout(fn, 0) is clamped to roughly 1ms and nested timers get clamped to 4ms after five levels; and in the browser, rendering happens between macrotasks, which is why a long microtask chain freezes the page while a chain of setTimeouts does not.
- Why does an infinite promise chain freeze the browser but an infinite setTimeout chain does not?
- Difference between setImmediate and setTimeout(fn, 0) in Node?
- Where does async/await sit in this model?
await?async makes a function return a promise. await suspends the function, registers the rest of the body as a .then callback on the awaited value, and returns control to the caller. The continuation therefore runs as a microtask — which is why the code after an await never runs synchronously, even when you await a value that is already resolved.
async function f() {
console.log('a')
await null // even a non-promise: still yields to the microtask queue
console.log('b') // this is a microtask continuation
}
f()
console.log('c')
// a c bThe practical consequence is sequencing. Two independent awaits run one after the other; if they do not depend on each other, that is wasted latency:
- Rewrite this to run them in parallel.
- What happens if one of them rejects?
- Does await block the event loop?
The three calls are independent, so the first version pays 600ms for 200ms of work. Note the subtlety worth saying out loud: the promises in Promise.all start executing the moment they are created, not when they are awaited — so even const a = getUser(); const b = getPosts(); await a; await b; is concurrent. It is the await on the call itself that serialises.
const user = await getUser(id) // 200ms
const posts = await getPosts(id) // 200ms — waits for the line above for no reason
const stats = await getStats(id) // 200msconst [user, posts, stats] = await Promise.all([
getUser(id), getPosts(id), getStats(id)
])Blindly converting every sequential await to Promise.all. If getPosts needs the user id from getUser, they are genuinely dependent and must be sequential. Show that you check the dependency before you parallelise.
- What if one fails and you still want the others?
- How would you limit this to 5 concurrent when there are 500?
Promise.all vs allSettled vs race vs any.| Combinator | Settles when | Reach for it when |
|---|---|---|
all | All fulfil, or the first rejects | You need every piece — a dashboard that is meaningless with a missing panel. |
allSettled | All settle, never rejects | Partial success is acceptable — fanning out to three third-party providers where one being down should not fail the request. |
race | First to settle, either way | Timeouts. Race the work against a rejecting timer. |
any | First to fulfil; rejects only if all reject | Redundant sources — three mirrors, take whichever answers first. |
// race gives you the timeout, but the losing request keeps running
const withTimeout = (p, ms) => Promise.race([
p,
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))
])
// promises are NOT cancellable. To actually stop the work:
const ac = new AbortController()
setTimeout(() => ac.abort(), 2000)
const res = await fetch(url, { signal: ac.signal })
// modern shorthand for exactly this:
await fetch(url, { signal: AbortSignal.timeout(2000) })The line that scores: "none of these cancel anything — Promise.all rejecting does not stop the sibling requests, they run to completion and their results are discarded. JavaScript promises are not cancellable, which is the entire reason AbortController exists."
- Implement Promise.all from scratch.
- How do you cancel an in-flight fetch on unmount?
- What does allSettled return for a rejected entry?
Promise.all from scratch.Three things to narrate while writing it: out[i] = v rather than out.push(v), because push gives you completion order not input order; the empty-array early return, because otherwise it never resolves; and that calling reject more than once is harmless, since a promise settles once and later calls are ignored.
function all(promises) {
return new Promise((resolve, reject) => {
const items = Array.from(promises)
const out = new Array(items.length)
let done = 0
if (items.length === 0) return resolve([]) // the case people miss
items.forEach((p, i) => {
// Promise.resolve handles non-promise values too
Promise.resolve(p).then(v => {
out[i] = v // index, not push
if (++done === items.length) resolve(out)
}, reject) // first rejection wins; later ones are no-ops
})
})
}- Now write allSettled.
- Now write a version that limits concurrency to N.
The detail that separates a good answer from a great one: tasks must be thunks. If you pass an array of promises they have already started and the pool controls nothing. Say that unprompted.
The other detail: this is a worker-pull design, not batching. Chunking into groups of five and awaiting each group means the whole group waits for its slowest member — with N workers pulling from a shared cursor, a fast worker immediately picks up the next item.
async function pool(tasks, limit = 5) {
const results = new Array(tasks.length)
let next = 0
async function worker() {
while (next < tasks.length) {
const i = next++ // grab an index, then release
results[i] = await tasks[i]()
}
}
// N workers all pulling from the same index — no batching stalls
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker))
return results
}
// tasks are FUNCTIONS returning promises, not promises —
// a promise has already started, so it cannot be throttled
await pool(urls.map(u => () => fetch(u)), 5)- What if one task throws?
- Add a retry with backoff.
- How would you preserve order if tasks finish out of order?
Two things they are listening for. Jitter: without it, a thousand clients that failed together retry together and re-create the exact spike that caused the outage — this is the thundering herd, and randomising the delay is the fix. And retryability: a 400 or a 404 will never succeed on retry, so retrying it just burns time and quota. Retry timeouts, 429s and 5xx; fail fast on 4xx.
async function retry(fn, { tries = 4, base = 300, factor = 2, jitter = true } = {}) {
let lastErr
for (let i = 0; i < tries; i++) {
try { return await fn() }
catch (err) {
lastErr = err
if (!isRetryable(err) || i === tries - 1) throw err
const wait = base * factor ** i
const delay = jitter ? wait * (0.5 + Math.random()) : wait
await new Promise(r => setTimeout(r, delay))
}
}
throw lastErr
}
// don't retry what will never succeed
const isRetryable = e =>
e.name === 'TimeoutError' || [429, 502, 503, 504].includes(e.status)- Where does a circuit breaker fit relative to this?
- What do you do after the last retry fails?
- Is retrying a POST safe?
A closure is a function together with the scope it was defined in, kept alive after that scope has returned. The definition is the cheap part — go straight to a real one:
function debounce(fn, ms) {
let timer // closed over, private, survives every call
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), ms)
}
}// BAD — every call adds a listener that captures the payload forever
function handle(req) {
const bigPayload = req.body
emitter.on('tick', () => log(bigPayload.id))
}
// FIX — remove it, or use once()
function handle(req) {
const onTick = () => log(req.body.id)
emitter.on('tick', onTick)
req.on('close', () => emitter.off('tick', onTick))
}Then the senior half: closures are the main way you leak memory in a long-lived Node process. A closure that captures a large object keeps it unreachable-for-collection as long as the returned function is referenced. The classic production leak is an event listener that closes over a request context and is never removed — every request adds a listener, each pinning its own context, and the heap climbs until the process dies.
- How would you find that leak in production?
- What does a WeakMap solve here?
- Why does a loop with var and setTimeout print the same number?
var is function-scoped, so all three callbacks close over the same binding. The loop finishes before any timer fires, and by then that single i is 3.
Two fixes, and knowing why the first works is the actual answer:
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i))
// 3 3 3// 1. let — a NEW binding per iteration, which the spec creates deliberately
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)) // 0 1 2
// 2. an IIFE capturing the value — how everyone did it before ES6
for (var i = 0; i < 3; i++) (j => setTimeout(() => console.log(j)))(i)- Does the same happen with a for…of loop?
- What about const in a for loop?
this. How is an arrow function different?this is decided at call time, not at definition time.this is determined by how the function is called, and there are exactly five rules, checked in this order:
new Fn()→ the newly created object.fn.call(x)/apply/bind→ whatever you passed.obj.fn()→obj, the thing before the dot.- Plain
fn()→undefinedin strict mode and modules,globalThisin sloppy mode. - Arrow function → none of the above. Arrows have no
thisbinding at all; they resolve it lexically from the enclosing scope, andbindcannot change it.
class Counter {
count = 0
inc() { this.count++ }
incArrow = () => { this.count++ } // class field: lexical this
}
const c = new Counter()
const f = c.inc
f() // TypeError — this is undefined, the dot is gone
f.call(c) // fix 1
const g = c.inc.bind(c) // fix 2
const h = c.incArrow // fix 3 — works detachedAnd the flip side that shows judgement: an arrow is wrong as an object method or on a prototype, because there is no dynamic this to pick up the instance — const o = { n: 1, get: () => this.n } is always broken.
- What is this inside a plain callback passed to forEach?
- Why do class methods need bind in React class components but not with arrow fields?
- Implement bind yourself.
bind.new case.Most candidates stop at fn.apply(ctx, [...bound, ...args]), which is a fine answer. The new handling is what gets you remembered — a bound function used as a constructor is supposed to ignore the bound this.
Function.prototype.myBind = function (ctx, ...bound) {
const fn = this
if (typeof fn !== 'function') throw new TypeError('not callable')
function wrapper(...args) {
// if called with new, ignore ctx and use the fresh instance
const calledWithNew = this instanceof wrapper
return fn.apply(calledWithNew ? this : ctx, [...bound, ...args])
}
wrapper.prototype = Object.create(fn.prototype || null)
return wrapper
}- What does partial application mean here?
- Can you bind an arrow function?
Every object has an internal link ([[Prototype]], reachable via Object.getPrototypeOf) to another object. Property lookup walks that chain until it finds the key or hits null. class is syntax over this: methods live on Constructor.prototype and are shared by every instance, which is why defining methods inside the constructor body instead wastes one function object per instance.
class A { hi() {} } // hi lives once, on A.prototype
function B() { this.hi = () => {} } // a new closure per instance
const a1 = new A(), a2 = new A()
a1.hi === a2.hi // true
// a prototype-free object is the correct shape for a lookup map:
const map = Object.create(null)
map.toString // undefined — no inherited keys to collide with
({}).toString // function — which is why {} as a map is a bug waiting__proto__ is the (deprecated) accessor for the link; prototype is a property that only functions have, and it is the object that will become the [[Prototype]] of instances they construct. Getting that distinction right in one sentence is most of the marks.
- What is prototype pollution and how do you prevent it?
- Difference between __proto__ and prototype?
- How does instanceof work?
var, let, const, and what is the temporal dead zone?var is function-scoped and hoisted initialised to undefined. let and const are block-scoped and hoisted uninitialised — the span between the top of the block and the declaration is the temporal dead zone, and touching the binding there throws a ReferenceError rather than silently giving undefined. That is the entire point of the TDZ: it turns a silent bug into a loud one.
const prevents rebinding, not mutation. const a = []; a.push(1) is legal. For real immutability you need Object.freeze, and that is shallow.
console.log(v) // undefined — hoisted and initialised
var v = 1
console.log(l) // ReferenceError: Cannot access 'l' before initialization
let l = 1
typeof undeclared // "undefined" — safe
typeof l // ReferenceError if l is in its TDZ — the one place typeof throws- Are function declarations hoisted differently from function expressions?
- Why is const the default in modern code?
structuredClone(obj) is the modern answer — built into browsers and Node, handles Date, Map, Set, RegExp, typed arrays, ArrayBuffer and circular references.
The answer most candidates give, JSON.parse(JSON.stringify(x)), silently destroys a lot:
const src = {
d: new Date(), m: new Map([['a',1]]), s: new Set([1]),
u: undefined, f: () => {}, n: NaN, i: Infinity, big: 10n
}
JSON.parse(JSON.stringify(src))
// d → "2026-09-05T..." a string, not a Date
// m,s → {} emptied
// u,f → dropped entirely (keys disappear)
// n,i → null
// big → throws TypeError
// circular → throwsfunction clone(v, seen = new WeakMap()) {
if (v === null || typeof v !== 'object') return v
if (seen.has(v)) return seen.get(v) // the cycle guard
const out = Array.isArray(v) ? [] : Object.create(Object.getPrototypeOf(v))
seen.set(v, out)
for (const k of Reflect.ownKeys(v)) out[k] = clone(v[k], seen)
return out
}What structuredClone still cannot do: functions, DOM nodes, class prototypes (you get a plain object back, not an instance), and getters/setters. If you need those, a hand-written recursive clone with a WeakMap of already-seen objects is the answer — and the WeakMap is what handles cycles.
- Why WeakMap and not Map here?
- What is a shallow clone and when is it enough?
Debounce waits for the input to stop: search-as-you-type, autosave, resize-then-recalculate. Throttle guarantees a maximum rate: scroll handlers, drag, mousemove, analytics pings.
Concrete answer for "where did you use it": the faceted search on your camp-booking marketplace was debounced at 300ms — without it, filtering across location, age, interest and price fired a request per keystroke per facet.
function debounce(fn, ms) {
let t
const wrapped = (...a) => {
clearTimeout(t)
t = setTimeout(() => fn(...a), ms)
}
wrapped.cancel = () => clearTimeout(t) // needed for cleanup on unmount
return wrapped
}function throttle(fn, ms) {
let last = 0, timer = null, lastArgs
return (...a) => {
const now = Date.now()
lastArgs = a
if (now - last >= ms) { last = now; fn(...a) }
else if (!timer) {
// trailing call, so the final event is not swallowed
timer = setTimeout(() => {
timer = null; last = Date.now(); fn(...lastArgs)
}, ms - (now - last))
}
}
}A throttle with no trailing call. The naive version drops the last event, so a user who stops scrolling mid-gesture never gets the final position and the UI ends up out of sync. Mentioning the trailing edge unprompted is the difference here.
- Which one would you use for an autosave?
- How do you cancel a pending debounce when a component unmounts?
- requestAnimationFrame vs throttle for scroll?
An event travels down from the root to the target (capture phase), fires on the target, then travels back up (bubble phase). Listeners default to the bubble phase; pass { capture: true } for the way down.
Delegation is putting one listener on a common ancestor and working out which descendant was hit — one listener instead of a thousand, and it keeps working for rows added to the DOM later:
list.addEventListener('click', (e) => {
const row = e.target.closest('[data-id]') // not e.target directly
if (!row || !list.contains(row)) return
open(row.dataset.id)
})e.target is what was actually clicked (possibly a span inside the row); e.currentTarget is the element the listener is on. Using closest() instead of e.target is what makes delegation robust against nested markup.
Also distinguish stopPropagation() (stop travelling) from preventDefault() (stop the browser's default action) — they are unrelated and candidates mix them up constantly. And note that some events do not bubble: focus, blur, load, mouseenter — which is why focusin and focusout exist.
- How does React attach its events?
- How do you delegate a focus event?
- What does passive: true do on a scroll listener?
== vs ===. Is there any legitimate use of ==?=== compares type and value. == applies the abstract equality algorithm, which coerces first — null == undefined is true, '1' == 1 is true, [] == false is true.
There is exactly one idiom worth keeping: x == null is true for precisely null and undefined and nothing else. It is the shortest correct nullish check and it is genuinely useful. Everywhere else, ===.
NaN === NaN // false — use Number.isNaN or Object.is
Object.is(NaN, NaN) // true
Object.is(0, -0) // false — the other case Object.is differs on
0 == '' // true
null == 0 // false (null only equals undefined)
[] == ![] // true — the party trick- How does Object.is differ from ===?
- What does the + operator do with an object?
- Why is typeof null "object"?
| Object | Map | |
|---|---|---|
| Keys | Strings and symbols only | Anything, including objects and NaN |
| Order | Integer-like keys sort first — surprising | Insertion order, always |
| Size | Object.keys(o).length — O(n) | map.size — O(1) |
| Inherited keys | Yes, unless Object.create(null) | Never |
| JSON | Serialises directly | Does not — needs conversion |
Rule of thumb: Map for a collection you add to and delete from at runtime; object for a fixed-shape record you will serialise.
Set gives O(1) membership against Array.includes at O(n) — this is the standard fix when a filter containing an includes turns quadratic and a page hangs at ten thousand rows.
// O(n·m) — 10k × 10k = 100 million comparisons
const missing = all.filter(x => !existing.includes(x.id))
// O(n + m)
const have = new Set(existing)
const missing = all.filter(x => !have.has(x.id))WeakMap holds its keys weakly: an entry disappears when nothing else references the key object. Use it to attach metadata to objects you do not own — caches keyed by an object, per-instance private data, or the cycle-guard in a deep clone — without preventing garbage collection. It is not enumerable and has no size, precisely because entries can vanish at any moment.
- Why can a WeakMap key not be a string?
- How would you build an LRU cache with a Map?
- What order does Object.keys return?
reduce actually do? Write groupBy with it.Say the accumulator sentence: reduce folds a collection into a single value by threading an accumulator through, and the accumulator can be any shape — a number, an object, a Map, another array.
const groupBy = (arr, keyOf) =>
arr.reduce((acc, item) => {
const k = keyOf(item)
;(acc[k] ||= []).push(item)
return acc
}, {})
groupBy(bookings, b => b.status)
// { pending: [...], confirmed: [...] }Modern runtimes have Object.groupBy(arr, fn) and Map.groupBy built in. Mentioning that you would reach for the built-in and only hand-roll for older targets is a small, cheap credibility win.
- Rewrite it to return a Map.
- When is reduce the wrong choice? (When a for…of is clearer — say so.)
import hoist but require not?require is a runtime function call: it executes wherever it appears, resolves synchronously, and returns a value you can compute — require(cond ? 'a' : 'b') is legal. import is a static declaration: the specifiers are parsed before any code runs, which is what makes the module graph knowable ahead of execution.
Three consequences worth naming:
- Hoisting. All imports are resolved and evaluated before the importing module's body runs.
- Live bindings. ESM imports are references to the exporting module's binding, not copies. If the exporter reassigns, the importer sees the new value. CommonJS copies the value at require time.
- Tree shaking. Only possible because the graph is static — a bundler can prove an export is unused. CommonJS cannot be shaken reliably.
// ESM live binding
// counter.js
export let n = 0
export const inc = () => n++
// main.js
import { n, inc } from './counter.js'
inc(); console.log(n) // 1 — CommonJS would print 0
// top-level await: ESM only
const cfg = await loadConfig()Also know the interop rule, because it bites in real projects: ESM can import CommonJS (the whole module.exports arrives as the default export), but CommonJS cannot require an ESM module — it must use dynamic import(), which is async. That asymmetry is why so many Node codebases stall halfway through the migration.
- What breaks tree shaking? (Side effects, barrel files, CommonJS.)
- What does "type": "module" do in package.json?
- What is a side-effectful import?
Tree shaking is dead-code elimination over the static ESM graph: the bundler proves an export is never used and drops it. Four things break it:
- Side effects. If a module does work at import time, the bundler cannot prove removing it is safe. Declaring
"sideEffects": falseinpackage.json(or listing the files that do have them) is what tells it otherwise. - CommonJS. Dynamic requires cannot be statically analysed.
- Barrel files. An
index.tsre-exporting everything makes one import pull the whole directory into the graph. This is the most common real cause and it is worth naming, because it is also a build-speed problem. - Namespace imports.
import * as _ from 'lodash'defeats it;import debounce from 'lodash/debounce'does not.
- How would you find what is making a bundle large?
- What is code splitting and how does it differ from tree shaking?
- null vs undefined.
undefinedmeans never assigned;nullis an assigned "nothing". Onlynullis intentional. - Why is
typeof null === "object"? A bug from 1995 kept for backwards compatibility. - Hoisting of functions. Function declarations are hoisted whole and callable before their line; function expressions assigned to
varareundefineduntil the assignment runs. - Currying.
const add = a => b => c => a + b + c— one argument at a time, returning a function until saturated. - IIFE. Pre-module scope isolation. With ESM it is largely obsolete; blocks and modules do the job.
??vs||.||falls through on any falsy value, so0 || 10is 10 — a real bug with counts and prices.??only falls through onnull/undefined.- Optional chaining.
a?.b?.()short-circuits toundefinedinstead of throwing. It does not protect against a missing variable, only a nullish property. - Generators. Functions that can pause and resume with
yield. Real sighting: Redux Saga, and lazy infinite sequences. Object.freeze. Shallow — nested objects stay mutable. Deep freeze needs recursion.- Symbol. A guaranteed-unique property key. Used for metadata that must not collide, and for protocol hooks like
Symbol.iterator. - Iterators. Anything with a
[Symbol.iterator]works withfor…ofand spread. That is how you make a custom class spreadable. - Event delegation vs direct binding. Fewer listeners, works for future nodes.
- Pass by value or reference? Always by value — but for objects the value is a reference.
slicevssplice.slicereturns a copy and does not mutate;splicemutates in place and returns what it removed.for…invsfor…of.inwalks enumerable keys including inherited ones;ofwalks values of an iterable. Almost never usefor…inon an array.