R3 · Part 2 · The technical rounds
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.
- service
- product
- saas
- agency
Everything open, top to bottom.
22On this page
- Explain the event loop. Where do promises and setTimeout sit?
- What actually happens when you await?
- These two look the same. Which is faster and why?
- Promise.all vs allSettled vs race vs any.
- Implement Promise.all from scratch.
- Write a promise pool that runs at most N tasks concurrently.
- Write retry with exponential backoff.
- What is a closure? Give me one from your own code.
- Why does this print 3, 3, 3 — and what are the two fixes?
- Explain this. How is an arrow function different?
- Implement bind.
- Explain the prototype chain.
- var, let, const, and what is the temporal dead zone?
- Deep clone an object. What are the failure modes?
- Implement debounce and throttle, and tell me where you used each.
- Explain event bubbling, capturing and delegation.
- == vs ===. Is there any legitimate use of ==?
- Map vs Object. Set vs Array. When would you use a WeakMap?
- What does reduce actually do? Write groupBy with it.
- ESM vs CommonJS — and why does import hoist but require not?
- What is tree shaking and what silently breaks it?
- Rapid-fire JavaScript — one clean sentence each
Explain the event loop. Where do promises and setTimeout sit?
What they are really testingThe single most-asked senior JavaScript question. They want the microtask/macrotask distinction, not "JavaScript is single-threaded and non-blocking".
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.
“JavaScript runs on one thread with a call stack. Async work — timers, network, promises — is handed off, and when it finishes the callback waits in a queue. The loop only takes from a queue when the stack is empty. There are two queues that matter: promise callbacks go to the microtask queue, setTimeout to the macrotask queue, and all microtasks drain before the next macrotask. So Promise.resolve().then(...) logs before setTimeout(..., 0) even though the timeout is zero.”
Why it passes here: That is a complete answer at two years — stack, handoff, both queues, and the priority between them, with the output you would predict. Do not add more. Anything you tack on that you cannot defend becomes the follow-up that undoes the rest.
“…and the part that matters in practice is that microtasks drain completely before the next macrotask, so a promise chain that keeps scheduling more promises can starve a timer indefinitely. I hit that on an import job that awaited inside a loop — the progress setTimeout never fired, so the UI looked frozen although the work was fine. Two fixes: break the chain by yielding to a macrotask, or stop awaiting in a loop and batch the requests, which is what we actually did.”
Why it lands: At six years the mechanism alone is table stakes — everyone has it. What separates you is knowing the failure mode. “A promise chain starves a timer” is the sentence; the story with the symptom attached is the proof you did not read it in a blog post that morning.
“Microtasks exist so a promise settling cannot be interleaved with rendering or with another task — you get a consistent state before the browser is allowed to do anything else. The cost is that the microtask queue sits between your code and the next frame, so a long await chain in a render path does not show up in a profiler as one slow function, it shows up as INP. Which is why the fix is usually scheduling and not optimisation: yield with scheduler.yield or a macrotask so the browser can paint, rather than trying to make the work itself faster.”
Why this is the level above: You connected a language-spec detail to a Core Web Vital and then proposed a remedy that is not “make it faster” — that is judgement, and it is what a staff interviewer is listening for. A ten-year candidate who recites the queue order and stops has just tested two levels down, and the debrief will say so.
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?
What actually happens when you await?
What they are really testingWhether you understand async/await as syntax over promises, or think it is a new concurrency primitive.
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 b"await blocks until the promise resolves." It pauses only the rest of this function. The thread goes straight back to the caller and keeps serving everything else — which is the whole reason Node can hold thousands of requests on one thread.
The 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?
These two look the same. Which is faster and why?
What they are really testingAccidental sequential awaits — one of the most common real performance bugs in Node codebases.
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.
What they are really testingWhether you have chosen between these in production rather than memorised four names.
| 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?
Implement Promise.all from scratch.
What they are really testingAsked constantly at senior level. The two failure points are preserving index order and handling the empty array.
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.
Write a promise pool that runs at most N tasks concurrently.
What they are really testingGenuinely useful and genuinely common at senior level — rate-limited third-party APIs, bulk imports, image processing.
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?
Write retry with exponential backoff.
What they are really testingWhether you know that naive retries make outages worse.
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?
What is a closure? Give me one from your own code.
What they are really testingEveryone has the definition. Almost nobody has the memory-leak half.
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))
}“A closure is a function that keeps access to the variables of the scope it was created in, even after that scope has finished running. So if I return a function from another function, the inner one still sees the outer one’s variables — they are not garbage collected, because something still references them. The usual example is a counter: makeCounter returns an increment that keeps its own count, private, with nothing on the global scope.”
Why it passes here: Correct plus walkable is the whole bar. Use the counter if that is what you genuinely know — but have the next answer ready, because the follow-up is always “where have you used one?” and a blank there costs more than the definition earned.
“Definition aside, the one I use constantly is debounce. The returned function closes over the timer variable, which is how it remembers the pending timeout between calls without a global or a class field — and because each call to debounce creates a fresh scope, two debounced inputs on the same page have separate timers instead of cancelling each other. Same shape in React: a callback closes over the props and state of the render it was created in, which is exactly where stale-closure bugs come from.”
Why it lands: Naming a real one, and naming what breaks without it, proves you understand the mechanism rather than the sentence. The bridge into the stale closure is deliberate — interviewers almost always take it, and you have just chosen the follow-up you are best prepared for.
“The scope half is well known. The half that costs money is that a closure keeps its whole enclosing scope reachable for as long as the function is reachable — so a listener that closes over a large object holds that object until the listener is removed. I found exactly that hunting a leak: a resize listener registered in a component that never unregistered, closing over a cached dataset, so every navigation added a copy that could never be collected. Retained size in the heap snapshot climbed per route change, and that was the signature. The rule I hold now is that anything long-lived closing over anything large is a leak waiting to happen — listeners, intervals, subscriptions, memo caches.”
Why this is the level above: At ten years “what is a closure” is a question about consequences. Retention, the tool you diagnosed it with, and a class of bug rather than one instance — that is the answer that gets written down verbatim in the debrief.
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?
Why does this print 3, 3, 3 — and what are the two fixes?
What they are really testingScope and closures together. Still asked because it separates people who understand binding from people who memorised "use let".
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?
Explain this. How is an arrow function different?
What they are really testingWhether you know that 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 detached"An arrow function's this is the object it is written in." It is the this of the enclosing scope, and an object literal is not a scope — which is exactly why { get: () => this.n } never sees n.
And 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.
Implement bind.
What they are really testingCombines this-binding, closures, rest args and — if you go all the way — the 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?
Explain the prototype chain.
What they are really testingWhether "class" means anything to you beyond syntax.
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?
What they are really testingHoisting. A one-line answer here is fine — a wrong one is fatal.
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"let and const are not hoisted." They are — the binding exists from the top of the block, which is why reading it early throws instead of quietly reaching an outer variable with the same name. What they are not is initialised.
- Are function declarations hoisted differently from function expressions?
- Why is const the default in modern code?
Deep clone an object. What are the failure modes?
What they are really testingWhether you still reach for the JSON trick and whether you know what it destroys.
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
}Offering JSON.parse(JSON.stringify(obj)) as the answer rather than as the thing to warn about. It turns Dates into strings, drops undefined and functions, empties Maps and Sets, and throws on cycles and BigInt — silently, except for the last two.
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?
Implement debounce and throttle, and tell me where you used each.
What they are really testingEveryone can define them. Few can write throttle correctly or name a real use.
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 clinic search on the booking platform was debounced at 300ms — without it, filtering across location, specialty, date 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?
Explain event bubbling, capturing and delegation.
What they are really testingDOM fundamentals, and whether you understand what React is doing under its synthetic events.
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 ==?
What they are really testingCoercion. Also whether you speak in absolutes or in judgement.
=== 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"?
Map vs Object. Set vs Array. When would you use a WeakMap?
What they are really testingWhether you pick data structures on purpose.
| 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?
What does reduce actually do? Write groupBy with it.
What they are really testingWhether you can use reduce for something other than summing an array.
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.)
ESM vs CommonJS — and why does import hoist but require not?
What they are really testingModule systems. Comes up constantly in Node interviews because teams are still mid-migration.
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?
What is tree shaking and what silently breaks it?
What they are really testingBundle size awareness — directly relevant to your page-load work.
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?
Rapid-fire JavaScript — one clean sentence each
What they are really testingBreadth. Service-company rounds run twenty of these in thirty minutes and score you on speed, not depth.
- 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.
Every answer above has a chapter behind it. These are the ones this round draws on, in the order they build.
- Execution context, Scope, this and Closures — hoisting, scope,
thisand closures, which is most of the first ten minutes - Async, properly and Advanced async — the event loop, microtasks, and the ordering questions
- Prototypes & OOP — the chain, and why
classis sugar over it - Objects deeply and Types & data — copying, equality, coercion
- Engine & memory — for when they push past the API into the machine
Runnable exercises sit under these chapters. Reading the answer is not the same as having typed it.
Finished this round?
0 of 27 rounds read in this book