JavaScript — Interview Questions
Answered the way you would say it in the room — first person, with an example every time. Tap a question to reveal the answer. Levels: Basic, Intermediate, Advanced.
1. How JavaScript Actually Runs
BasicIs JavaScript an interpreted language or a compiled one?
Honestly, it's both. Modern engines use JIT compilation, which stands for just-in-time. The code starts running quickly as bytecode, and then the engine compiles the hot parts to machine code while the program runs.
In V8, the pipeline looks roughly like this:
source text
→ parser → AST
→ Ignition → bytecode (starts running immediately)
→ Sparkplug / Maglev → quicker, lightly optimized machine code
→ TurboFan → heavily optimized machine code for "hot" functions
(and back to bytecode = "deopt" if assumptions break)
So if I call a function like add(a, b) ten thousand times and always pass numbers, TurboFan compiles a version that assumes numbers. If I suddenly pass a string, that assumption breaks. The engine deoptimizes and falls back to bytecode. That's why "interpreted" is an outdated label. It's a dynamic language running on an adaptive compiler.
Interviewers look for: the word "deoptimization" and the idea that engines optimize from observed types. Many candidates stop at "it's interpreted."
BasicWhat's the difference between the JavaScript engine and the runtime?
So the engine is the part that actually understands the language: parsing, compiling, the call stack, the heap, garbage collection. V8, SpiderMonkey and JavaScriptCore are engines. The runtime is the engine plus everything around it: the APIs, the event loop and the task queues.
A quick way to show it: setTimeout isn't JavaScript at all.
console.log(typeof Promise); // "function" (language, from the engine)
console.log(typeof setTimeout); // "function" (provided by the host)
console.log(typeof document); // "object" in a browser, "undefined" in Node
console.log(typeof process); // "object" in Node, "undefined" in a browser
Chrome and Node both run on V8, but Chrome gives me document, fetch and the DOM event system, while Node gives me fs, process and libuv for I/O. The language spec doesn't even define an event loop. The host environment does. So when someone says "JavaScript does X asynchronously," what's really happening is that the runtime does it and hands the result back to the engine.
BasicJavaScript is single-threaded. So how does it make a network request without freezing the page?
So my code runs on one thread, but the waiting doesn't happen on that thread. When I call fetch, the browser does the networking on its own threads, and my code keeps going. When the response arrives, a callback is queued, and the event loop runs it once the call stack is empty.
console.log("1: start");
fetch("/api/user").then(() => console.log("3: got user"));
console.log("2: keep going");
// 1: start
// 2: keep going
// 3: got user (whenever the network finishes)
The catch is that this only helps with waiting. If I do heavy computation on the main thread, like parsing a 50MB JSON file or looping over a million rows, nothing else can run. That includes clicks and rendering. For that I'd move the work to a Web Worker or split it into chunks.
Interviewers look for: the difference between I/O-bound work (handled by the runtime) and CPU-bound work (it blocks the thread). The follow-up is usually "how would you keep a long computation from freezing the UI?"
IntermediateWhat happens between the browser downloading a script and the first line of it executing?
So there are three phases: parse, compile, then execute. And the important part is that the whole script is parsed before any of it runs.
Here's how I usually prove that:
console.log("will this print?");
function broken( { // syntax error further down
Nothing prints. The parser hits the syntax error first, so the script never starts running. A runtime error behaves differently: everything before it runs.
In more detail, the engine tokenizes the source and builds an AST. V8 parses lazily: it "pre-parses" inner functions just enough to check syntax and find variables, and fully compiles them only when they're first called. Then Ignition generates bytecode for the top level. After that, the global execution context is created. That's the hoisting step, where var and function declarations are set up. Only then does the first line run.
In practice this is why shipping less JavaScript matters even if it never runs. Parse and compile time on a mid-range phone is a real part of page load.
IntermediatePredict the output, and tell me how long the timer really waits.
const start = Date.now();
setTimeout(() => console.log("timer", Date.now() - start), 0);
while (Date.now() - start < 1000) {} // busy-wait 1 second
console.log("done");
So it prints done first, and then timer with roughly 1000, not 0.
The 0 in setTimeout is a minimum delay, not a guarantee. The timer does expire almost at once, and its callback goes into the task queue. But the event loop only takes the next task when the call stack is empty. My while loop holds the thread for a full second, so the callback just sits there.
I've seen this in real apps: a big synchronous sort on page load made every click handler and animation lag by the same amount. The fix wasn't adjusting any timers. It was taking the long task off the main thread.
Interviewers look for: "the delay is a minimum." Bonus points for knowing that nested timers get clamped to about 4ms after five levels in browsers.
IntermediateWhat are the practical differences between running JS in the browser and in Node?
So the language is the same. What differs is the host: the globals, the APIs, the module defaults and a few event-loop details.
The things I actually run into are:
// Global object
globalThis === window; // browser
globalThis === global; // Node
// APIs
document.querySelector("#app"); // browser only
import { readFile } from "node:fs/promises"; // Node only
// Scheduling
process.nextTick(cb); // Node: runs before promise microtasks
setImmediate(cb); // Node: runs in the "check" phase after I/O
Modules are another one. Browsers only understand ES modules (type="module"). Node supports both CommonJS and ESM, and it decides which one a file is from "type" in package.json or the .mjs/.cjs extension. And a lot of browser APIs have made it into Node now: fetch, URL, structuredClone, Web Streams. So code sharing is much easier than it used to be.
In practice, for shared code in a Next.js app, I keep anything that touches window or document out of code that runs on the server, or I guard it. Otherwise it crashes during server rendering.
AdvancedHow does V8 make property access on dynamic objects fast, and how can you accidentally make it slow?
So V8 gives every object a hidden "shape" behind the scenes. V8 calls these maps, and they're sometimes called hidden classes. Objects created the same way share a shape, and each place in the code that reads a property keeps an inline cache that remembers where that property lives for the shape it saw.
function Point(x, y) { this.x = x; this.y = y; }
const a = new Point(1, 2);
const b = new Point(3, 4); // same shape as a
const c = { y: 4, x: 3 }; // different order = different shape
const d = { x: 1 }; d.z = 5; d.y = 2; // yet another shape
function getX(p) { return p.x; }
// Called only with a and b: "monomorphic", very fast.
// Called with a, c, d and more: "polymorphic", then "megamorphic", slower.
The ways I've seen people slow it down are adding properties in different orders, adding properties conditionally after creation, using delete on hot objects (that can push the object into slow "dictionary mode"), and mixing types, like an array that holds both small integers and objects.
In practice I don't micro-optimize for this in normal app code. But in a hot loop, like a rendering engine or a data parser, I initialize all fields in the constructor in the same order and set a field to undefined instead of deleting it.
Interviewers look for: "inline caches" and "monomorphic." A senior candidate also says they'd profile before optimizing.
AdvancedIf JavaScript is single-threaded, what are Web Workers and SharedArrayBuffer doing?
So the accurate statement is that each JavaScript agent is single-threaded. A worker is a separate agent: its own thread, its own heap, its own event loop. Two pieces of code never run at the same time on the same objects, except through SharedArrayBuffer.
// main.js
const worker = new Worker("hash.js", { type: "module" });
worker.postMessage(bigFile); // copied with structured clone
worker.onmessage = (e) => show(e.data);
// hash.js
self.onmessage = async (e) => {
const digest = await crypto.subtle.digest("SHA-256", e.data);
self.postMessage(digest);
};
By default postMessage copies the data, so there's no shared state and no race conditions. For large binary data I can transfer an ArrayBuffer instead, which moves ownership at no copying cost. SharedArrayBuffer is the one exception where two threads see the same memory. Then I need Atomics to coordinate, and the page has to be cross-origin isolated (the COOP/COEP headers) because of Spectre.
In a real project I moved CSV parsing for a 200k-row import into a worker. The main thread stayed responsive, and I just showed a progress bar from the worker's messages.
2. Types & Memory
BasicWhat types does JavaScript have, and what's the real difference between a primitive and an object?
So there are seven primitives: string, number, bigint, boolean, undefined, null and symbol. Everything else is an object, including arrays, functions, dates and maps.
The real difference is that primitives are immutable values, while an object is a mutable thing that variables point to.
let s = "hi";
s.toUpperCase(); // returns "HI", but s is still "hi"
s[0] = "X"; // silently ignored, strings can't change
const user = { name: "Ana" };
const copy = user;
copy.name = "Bo";
console.log(user.name); // "Bo" (same object)
console.log({} === {}); // false (two different objects)
That last line is the one that matters day to day. In React, if I mutate an object in state and call setState with the same reference, React compares with Object.is, sees "no change," and skips the re-render. That's why I always create a new object: setUser({ ...user, name: "Bo" }).
BasicWhat's the difference between null and undefined, and when do you use each?
So undefined means "this was never given a value." The language hands it to you. null means "there's intentionally nothing here." A developer assigns it on purpose.
let a; // undefined: declared, never assigned
const o = {};
o.missing; // undefined: property doesn't exist
function f(x) { return x; }
f(); // undefined: argument not passed
let selectedUser = null; // I'm saying: explicitly no selection yet
typeof undefined; // "undefined"
typeof null; // "object" (a historical bug)
null == undefined; // true
null === undefined; // false
JSON.stringify({ a: undefined, b: null }); // '{"b":null}'
That JSON line is a gotcha I've hit: undefined properties just vanish when you serialize. So if an API needs to know "clear this field," I send null. Default parameters also only apply for undefined, not null.
My rule is to let the language produce undefined, use null when I mean "empty on purpose," and check for both at once with x == null or x ?? fallback.
BasicWhat does typeof return for these, and which ones are surprising?
typeof null // "object" (surprising)
typeof [] // "object" (surprising)
typeof NaN // "number" (surprising)
typeof function () {} // "function"
typeof class {} // "function" (classes are functions)
typeof 10n // "bigint"
typeof Symbol() // "symbol"
typeof notDeclared // "undefined" (no ReferenceError)
So the three that trip people up are null, arrays and NaN. typeof null being "object" is a bug from the very first implementation that can never be fixed without breaking the web. Arrays are just objects. And NaN is technically a numeric value that means "not a valid number."
So in real code I don't rely on typeof alone:
Array.isArray(value); // arrays
value === null; // null
Number.isNaN(value); // NaN
value !== null && typeof value === "object"; // "plain object-ish"
Interviewers look for: Array.isArray instead of instanceof Array, which fails across iframes. The trap is typeof x === "object" letting null through.
IntermediateIs JavaScript pass-by-value or pass-by-reference?
So it's always pass-by-value. But when the value is an object, the value being copied is a reference. People call it "pass by sharing." The easiest way to prove it's not true pass-by-reference is to compare mutating with reassigning.
function mutate(obj) { obj.count = 99; }
function reassign(obj) { obj = { count: 42 }; }
const data = { count: 1 };
mutate(data);
console.log(data.count); // 99 (we changed the shared object)
reassign(data);
console.log(data.count); // still 99 (we only rebound the local copy)
Inside reassign, obj is a local variable that starts out holding the same reference. Pointing it at a new object doesn't touch the caller's variable. In a true pass-by-reference language like C++ with &, the caller's variable would change.
The practical takeaway is that any function I pass an object to can mutate it. So for utility functions I either document that they mutate, or I return a new object. A bug I've actually fixed was a helper that called .sort() on an array prop, which silently reordered the parent's data.
IntermediatePeople say primitives live on the stack and objects on the heap. Is that accurate?
So it's a useful mental model, but it isn't literally how V8 works, and I'd say that out loud in an interview.
The model says local primitives sit in the stack frame and are copied on assignment. Objects live in the heap, and variables hold a pointer to them. That correctly predicts behaviour: copying a number gives an independent copy, and copying an object gives a shared pointer.
function f() {
let n = 5; // model: stack
let user = { n }; // model: pointer on stack, object on heap
return () => n; // but n is captured by a closure...
}
In reality, V8 stores small integers ("Smis") directly as tagged values, but most other things are heap-allocated, including strings and non-small numbers. And any variable captured by a closure, like n above, can't live on the stack at all, because it has to outlive the function call. So V8 puts it in a heap-allocated "context" object.
So for predicting behaviour I think "values vs references." For performance and memory, what actually matters is what's reachable from the roots, because that's what the garbage collector keeps alive.
IntermediateWhy is 0.1 + 0.2 !== 0.3, and when do you reach for BigInt?
So every number in JavaScript is a 64-bit IEEE 754 float. Numbers like 0.1 and 0.2 can't be stored exactly in binary, so small rounding errors appear.
0.1 + 0.2; // 0.30000000000000004
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // true (compare with tolerance)
Number.MAX_SAFE_INTEGER; // 9007199254740991 (2^53 - 1)
2 ** 53 === 2 ** 53 + 1; // true, integers are no longer exact
const big = 2n ** 64n; // 18446744073709551616n
typeof big; // "bigint"
big + 1; // TypeError: can't mix BigInt and Number
big + 1n; // fine
7n / 2n; // 3n (integer division, no decimals)
I reach for BigInt when I need exact integers above 2^53. The real case I've seen is 64-bit IDs from a backend, like Twitter-style snowflake IDs. Parsing them with JSON.parse silently corrupted the last digits. The simplest fix there was having the API send them as strings.
For money, I don't use floats or BigInt directly. I store integer cents, and use Intl.NumberFormat for display.
Interviewers look for: "store money as integer cents." A follow-up can be why JSON.stringify(1n) throws. BigInt has no JSON representation by default.
IntermediateWhat is a Symbol, and have you ever actually needed one?
So a Symbol is a primitive that's guaranteed to be unique. Its main use is as a property key that can't collide with anyone else's keys and doesn't show up in normal enumeration.
const id = Symbol("id");
const user = { name: "Ana", [id]: 123 };
Symbol("id") === Symbol("id"); // false (always unique)
Object.keys(user); // ["name"]
JSON.stringify(user); // '{"name":"Ana"}'
user[id]; // 123
Object.getOwnPropertySymbols(user); // [Symbol(id)]
Symbol.for("app.id") === Symbol.for("app.id"); // true (global registry)
The place I actually use symbols is the well-known symbols, which let my objects plug into language features. For example, Symbol.iterator makes an object work with for...of and spread:
class Range {
constructor(from, to) { this.from = from; this.to = to; }
*[Symbol.iterator]() { for (let i = this.from; i <= this.to; i++) yield i; }
}
[...new Range(1, 3)]; // [1, 2, 3]
They're not truly private, though. getOwnPropertySymbols and Reflect.ownKeys find them. If I need real privacy I use #private fields.
AdvancedHow do you copy an object, and what goes wrong with the usual approaches?
So spread and Object.assign are shallow. They copy only the top level, and nested objects are still shared. For a deep copy today I use structuredClone.
const original = {
name: "Ana",
prefs: { theme: "dark" },
created: new Date(),
};
const shallow = { ...original };
shallow.prefs.theme = "light";
console.log(original.prefs.theme); // "light" (nested object was shared)
const viaJson = JSON.parse(JSON.stringify(original));
typeof viaJson.created; // "string" (Date got flattened)
const deep = structuredClone(original);
deep.created instanceof Date; // true
The JSON trick is the one I see most in old code, and it has real problems. It drops undefined and functions, turns Date into strings, turns Map/Set into {}, throws on BigInt and on circular references. structuredClone handles Dates, Maps, Sets, typed arrays and circular references. But it throws on functions and DOM nodes, and it doesn't keep class prototypes, so a class instance comes back as a plain object.
In practice, with immutable updates in React or Redux, I rarely deep-clone at all. I copy only the path I'm changing, like { ...state, prefs: { ...state.prefs, theme } }. That's cheaper and keeps unchanged references stable, so memoized components don't re-render.
Interviewers look for: the list of JSON round-trip losses. They may also ask you to write a recursive deep clone that handles cycles with a WeakMap.
3. Coercion & Equality
BasicYou always use ===. Is there ever a good reason to use ==?
So yes, there's exactly one I use deliberately: x == null. It's true for both null and undefined and nothing else, so it's a clean "is this missing?" check.
value == null; // true only for null or undefined
// same as:
value === null || value === undefined;
// Everything else about == is a trap:
0 == ""; // true
0 == "0"; // true
"" == "0"; // false (not even transitive)
false == "0"; // true
null == 0; // false (null only loosely equals undefined)
The difference is that === compares type and value with no conversion. == runs the Abstract Equality algorithm, which converts one or both sides first, usually toward numbers. That's how you get the non-transitive result above.
So on a team I'd enable ESLint's eqeqeq rule with the "null": "ignore" option. That bans loose equality everywhere except the null check.
BasicHow do you check whether a value is NaN, and why can't you just use ===?
So NaN is the only value in JavaScript that isn't equal to itself. That's defined by the IEEE float standard, so x === NaN is always false. I use Number.isNaN.
NaN === NaN; // false
[NaN].indexOf(NaN); // -1 (uses ===)
[NaN].includes(NaN); // true (uses SameValueZero)
isNaN("hello"); // true (coerces "hello" to NaN first, misleading)
Number.isNaN("hello"); // false (it's a string, not NaN)
Number.isNaN(0 / 0); // true
Object.is(NaN, NaN); // true
The global isNaN is the gotcha. It coerces its argument to a number first, so it really answers "would this become NaN?" That's a different question. I got bitten by this validating form input. isNaN(" ") is false because a blank string coerces to 0, so an empty-looking field passed validation.
So in practice I use Number.isNaN for "is this literally NaN." For input validation I parse first and then check with Number.isFinite(Number(str)), plus an explicit empty-string check.
IntermediateWhat's the difference between Object.is and ===, and where does it matter?
So they agree on everything except two edge cases: NaN and signed zero.
NaN === NaN; // false
Object.is(NaN, NaN); // true
0 === -0; // true
Object.is(0, -0); // false
Object.is is the "SameValue" algorithm, meaning "are these literally the same value." It's where I've actually seen it matter:
First, React uses Object.is to decide whether state changed and whether hook dependencies changed. So setCount(NaN) twice correctly bails out the second time. With === it would re-render forever in some patterns.
Second, -0 shows up in maths more than people expect, like Math.round(-0.4) or -1 * 0. If I'm displaying a direction or a sign, Object.is(x, -0) is the only easy way to detect it. String(-0) gives "0", so logs hide it.
For the record, includes, Map and Set use a third one, SameValueZero. It's like Object.is, but treats 0 and -0 as equal.
Interviewers look for: naming all three algorithms, strict equality, SameValue and SameValueZero, and connecting Object.is to React's bail-out logic.
IntermediateWalk me through why [] == ![] is true.
So it looks like "something equals not itself," but the two sides get converted step by step until both are 0.
[] == ![]
// 1. The right side is evaluated first: [] is an object, so it's truthy
// ![] → false
[] == false
// 2. With a boolean on one side, == converts it to a number
[] == 0
// 3. Object vs number: convert the object with ToPrimitive
// [].valueOf() is the array itself (not primitive), so try toString()
// [].toString() → ""
"" == 0
// 4. String vs number: convert the string to a number
0 == 0 // true
The rules that do all the work are: booleans become numbers, objects become primitives through valueOf or toString, and strings become numbers when compared with numbers.
By the same logic, [0] == false is true but if ([0]) still runs, because truthiness never calls ==. An object is always truthy. That split between "truthy" and "loosely equal to true" is exactly why I avoid == with booleans.
IntermediatePredict each of these results.
"5" + 2 // ?
"5" - 2 // ?
"5" * "2" // ?
true + 1 // ?
null + 1 // ?
undefined + 1 // ?
[] + [] // ?
[] + {} // ?
[1, 2] + [3] // ?
So the rule I apply is that + is special. If either side becomes a string after ToPrimitive, it concatenates. Every other arithmetic operator always converts to numbers.
"5" + 2 // "52" (string wins with +)
"5" - 2 // 3 (minus is numeric only)
"5" * "2" // 10
true + 1 // 2 (true → 1)
null + 1 // 1 (null → 0)
undefined + 1 // NaN (undefined → NaN)
[] + [] // "" ("" + "")
[] + {} // "[object Object]"
[1, 2] + [3] // "1,23" ("1,2" + "3")
A bonus I'd mention: typing {} + [] at the start of a line in the console can give 0, because the {} is parsed as an empty block and then +[] is unary plus. Wrap it in parentheses and you get "[object Object]".
Where this bites in real code is form values. input.value is always a string, so input.value + 1 concatenates. I convert explicitly with Number() or use valueAsNumber.
IntermediateThis code sorts some numbers but the result is wrong. Why?
const scores = [10, 9, 100, 1, 25];
scores.sort();
console.log(scores); // [1, 10, 100, 25, 9]
So the bug is that sort() with no comparator converts every element to a string and compares them as UTF-16 strings. As strings, "100" comes before "25" because "1" is less than "2". It's coercion hiding inside a built-in.
scores.sort((a, b) => a - b); // [1, 9, 10, 25, 100]
const sorted = scores.toSorted((a, b) => a - b); // ES2023, doesn't mutate
"10" < "9"; // true (string comparison, same trap)
"10" < 9; // false (one side is a number, so both become numbers)
The comparator returns a negative number, zero, or a positive number, so a - b gives ascending order. For strings with accents or mixed case I use a.localeCompare(b) or Intl.Collator.
The second thing I'd point out is that sort mutates in place. If scores were a React prop or state, that's a bug on its own. So in modern code I default to toSorted.
Interviewers look for: both bugs, the string comparison and the mutation. Bonus for knowing that sort has been guaranteed stable since ES2019.
AdvancedHow does JavaScript turn an object into a primitive, and how can you control it?
So it runs an internal operation called ToPrimitive with a "hint": "number", "string" or "default". First it checks for a Symbol.toPrimitive method. If there isn't one, it tries valueOf then toString for a number or default hint, and the reverse order for a string hint.
const price = {
amount: 42,
currency: "USD",
[Symbol.toPrimitive](hint) {
if (hint === "number") return this.amount;
if (hint === "string") return `${this.amount} ${this.currency}`;
return this.amount; // "default" hint: used by + and ==
},
};
+price; // 42 (hint "number")
`${price}`; // "42 USD" (hint "string")
price + 1; // 43 (hint "default")
price * 2; // 84
Without that method, a plain object falls back to valueOf, which returns the object itself (not a primitive), then to toString, which gives "[object Object]". Date is the odd one out: it treats the default hint as string, so date + 1 concatenates, while date - 0 gives the timestamp.
In practice I'd rarely define this in app code, because implicit conversion makes code harder to read. But it explains a lot of "wat" output, and it's how things like money or vector libraries make +obj behave nicely.
4. Operators & Truthiness
BasicList the falsy values. Which truthy values surprise people?
So there are exactly eight falsy values. Everything else is truthy.
false, 0, -0, 0n, "", null, undefined, NaN
// (plus the legacy document.all in browsers)
// Truthy, but people expect falsy:
Boolean("0"); // true (non-empty string)
Boolean("false"); // true
Boolean([]); // true (every object is truthy)
Boolean({}); // true
Boolean(new Boolean(false)); // true (it's an object wrapper)
The empty array is the one that causes real bugs. I've seen if (results) showResults() where results was [], so the "no results" message never appeared. The fix is to be explicit about what you mean: if (results.length > 0).
The other one is numbers. if (user.age) treats an age of 0 as missing. So my habit is to use a truthiness check only when every falsy value really means "absent." For numbers and strings from forms, I check for null/undefined directly.
BasicWhat's the difference between || and ??? Show me a bug the first one causes.
So || falls back on any falsy value. ??, the nullish coalescing operator, falls back only on null or undefined. The bug happens when 0, "" or false are valid values.
function createServer(options) {
const port = options.port || 3000; // bug: port 0 becomes 3000
const retries = options.retries || 3; // bug: retries 0 becomes 3
const verbose = options.verbose || true; // bug: can never be false
}
// fixed
const port = options.port ?? 3000;
const retries = options.retries ?? 3;
const verbose = options.verbose ?? true;
I hit exactly the verbose one in a real project. There was a feature flag defaulting with || true, so turning the feature off in config did nothing.
One syntax detail: you can't mix ?? with || or && without parentheses. a || b ?? c is a SyntaxError. That was a deliberate design choice to force you to state what you mean.
So my default is ?? for defaults. I use || only when an empty string or zero really should be replaced, like a display name where "" should show "Anonymous."
BasicHow does optional chaining work, and where does it stop protecting you?
So ?. checks whether the thing on its left is null or undefined. If it is, the whole rest of the chain short-circuits and returns undefined instead of throwing.
const user = { profile: null };
user.profile.name; // TypeError
user.profile?.name; // undefined
user.profile?.address.city; // undefined (the whole chain stops)
user.settings?.theme; // undefined
user.onSave?.(); // call only if the method exists
list?.[0]; // safe index access
user.profile?.name = "Ana"; // SyntaxError, can't assign through ?.
user?.profile.name; // TypeError! profile is null, and ?. only guarded user
That last line is the gotcha. ?. protects only the step right after it. user exists, so we move on, and then .name on null throws. You need ?. at every link that can actually be missing.
My other caution is not to sprinkle it everywhere. If user should always exist in a given place, user?.x just hides the bug where it's missing, and you get a blank screen instead of a clear error. I use it at real boundaries, like API data or optional props.
IntermediateWhat do && and || actually return? Why does this React code render a stray 0?
So they don't return booleans. They return one of the operands. && returns the first falsy operand, or the last one if all are truthy. || returns the first truthy operand, or the last one.
"a" && "b"; // "b"
0 && "b"; // 0
null || "x"; // "x"
0 || null; // null
"" || 0 || NaN; // NaN (all falsy, so the last one)
// React:
{messages.length && <Badge count={messages.length} />}
// when length is 0 the expression evaluates to 0,
// and React renders the number 0 on the screen
React skips false, null and undefined, but it renders numbers. So an empty inbox shows a lonely "0." The fix is to make the left side a real boolean:
{messages.length > 0 && <Badge count={messages.length} />}
// or a ternary: {messages.length ? <Badge /> : null}
The same short-circuit rule means the right side doesn't run at all if it isn't needed. That's why isLoggedIn && loadDashboard() works as a conditional call, though in normal code I'd write an if for readability.
IntermediatePredict the output.
let i = 1;
const a = i++ + ++i;
console.log(a, i);
let j = 5;
const b = j-- - --j;
console.log(b, j);
So it prints 4 3 and then 2 3.
The rule is that postfix i++ returns the old value and then increments. Prefix ++i increments first and returns the new value. And evaluation goes strictly left to right.
// a = i++ + ++i
// i++ → returns 1, i becomes 2
// ++i → i becomes 3, returns 3
// a = 1 + 3 = 4, i = 3
// b = j-- - --j
// j-- → returns 5, j becomes 4
// --j → j becomes 3, returns 3
// b = 5 - 3 = 2, j = 3
I'd add that I'd never write this in real code, and a reviewer would flag it. I only use ++ alone on its own line or in a for header, where pre versus post doesn't matter.
Interviewers look for: a calm step-by-step trace, not a guess. Saying "left to right, postfix returns the old value" out loud earns most of the credit.
IntermediateWhat are the logical assignment operators, and is a ||= b the same as a = a || b?
So there are three: ||=, &&= and ??=. They're almost the same as the long form, with one difference: the assignment itself is short-circuited. If no change is needed, no assignment happens at all.
options.retries ??= 3; // set only if null/undefined
user.nickname ||= "Guest"; // set if falsy
session.token &&= refresh(session.token); // update only if one exists
// Why "no assignment" matters:
const cfg = {
get mode() { return "dark"; },
set mode(v) { console.log("setter ran"); },
};
cfg.mode ||= "light"; // nothing logged, setter never called
cfg.mode = cfg.mode || "light"; // logs "setter ran"
That difference is invisible with plain variables. But with setters, Proxies, reactive state like Vue refs or MobX, or frozen objects, it changes behaviour. The short form doesn't trigger a write, while the long form triggers a pointless one. On a frozen object in strict mode, the long form even throws.
In practice I use ??= most, for lazy initialization like cache[key] ??= compute(key).
AdvancedWhat does the comma operator do, and why does (0, obj.method)() show up in bundled code?
So the comma operator evaluates each expression left to right and returns the last one. You mostly see it in for loops. The bundler trick comes from the fact that it turns a property reference into a plain value, which drops this.
const x = (1, 2, 3); // 3
for (let i = 0, j = 10; i < j; i++, j--) { /* two counters */ }
const obj = {
name: "obj",
who() { return this?.name; },
};
obj.who(); // "obj" (called as a method, so this = obj)
(obj.who)(); // "obj" (parentheses alone keep the reference)
(0, obj.who)(); // undefined (the comma produced a bare function)
When a bundler like webpack rewrites import { fn } from "./mod" into mod.fn(), it would accidentally call fn with this = mod. That isn't how a real ES module import behaves. So it emits (0, mod.fn)() to call it with this as undefined, matching the spec. I noticed this reading a stack trace in a production bundle once, and it's what made the operator click for me.
In my own code I don't use the comma operator outside for headers. It's too easy to misread.
5. Execution Context & the Call Stack
BasicWhat is an execution context?
So an execution context is the environment a piece of code runs in. It tracks which variables are visible, what this is, and where to return when the code finishes. There's one global context when a script starts, and a new function context is created every time a function is called, not when it's defined.
Each context holds three main things:
ExecutionContext {
LexicalEnvironment: // let, const, class, function bindings, plus an outer link
VariableEnvironment: // var bindings
ThisBinding: // the value of `this`
}
const rate = 0.2; // global context
function tax(amount) { // each call gets its own context
const t = amount * rate; // t is new per call
return t;
}
tax(100); // context #1 created, then destroyed
tax(250); // context #2, a totally separate `t`
The practical takeaway is that every call gets fresh local variables, which is why recursion works. But the outer link is fixed by where the function was written. That's what gives us lexical scope and closures.
BasicWhat is the call stack? Walk me through it for this code.
function a() { b(); console.log("a done"); }
function b() { c(); console.log("b done"); }
function c() { console.log("in c"); }
a();
So the call stack is how the engine keeps track of which function is running and where to go back to. Calling a function pushes a frame on top, and returning pops it off. The engine always runs whatever frame is on top.
[global]
[global, a] // a() called
[global, a, b] // b() called from a
[global, a, b, c] // c() called, logs "in c"
[global, a, b] // c returns, b logs "b done"
[global, a] // b returns, a logs "a done"
[global]
// Output: in c, b done, a done
This is also exactly what you see in an error's stack trace, read top to bottom from the most recent call. When I'm debugging, the "Call Stack" panel in DevTools is literally this list, and clicking a frame shows the local variables for that call.
And because there's only one stack, nothing else, like a click handler or a timer, can run until it's back down to empty. That's the link to the event loop.
IntermediateWhat happens in the creation phase versus the execution phase of a context?
So before any line of a function runs, the engine does a setup pass called the creation phase. It scans for declarations and creates their bindings. Then the execution phase runs the code line by line and assigns values.
function demo() {
console.log(v); // undefined
console.log(greet());// "hi"
console.log(l); // ReferenceError
var v = 1;
let l = 2;
function greet() { return "hi"; }
}
Here's what the creation phase set up for demo:
// creation phase
v → undefined (var: created and initialized)
greet → <function object> (function declaration: fully ready)
l → <uninitialized> (let/const: created, but in the TDZ)
this, arguments, outer reference are set too
Then execution runs top to bottom. v reads undefined, greet already works, and l throws because it's still uninitialized until its let line runs.
That's really all "hoisting" is: an effect of the creation phase. Nothing physically moves. Once I explain it this way, the differences between var, let and function declarations stop being rules to memorize.
Interviewers look for: the three different initial states: undefined, a full function, and uninitialized.
IntermediateWhat causes "Maximum call stack size exceeded," and how do you fix it?
So it's a RangeError you get when the stack runs out of room, almost always from recursion that never stops or goes too deep. The limit is engine-dependent, typically around ten thousand frames.
// 1. Missing or wrong base case
function countdown(n) {
if (n === 0) return; // if this were n === -1 with odd steps...
countdown(n - 1);
}
// 2. Accidental recursion, one I've actually debugged
class User {
set name(v) { this.name = v; } // setter calls itself forever
}
// 3. Legitimately deep input
function sumTree(node) {
return node ? node.value + sumTree(node.next) : 0;
}
sumTree(linkedListWith100kNodes); // RangeError
For the first two, the fix is the logic: a correct base case, or storing to a private field like this.#name = v. For the third, where the data really is that deep, I switch to iteration:
function sumList(node) {
let total = 0;
for (let n = node; n; n = n.next) total += n.value;
return total;
}
People sometimes suggest tail calls. ES2015 specifies proper tail calls, but only Safari implements them, so I can't rely on that. For tree traversals I use an explicit stack array instead.
IntermediateHow is the lexical environment different from the call stack?
So the call stack is about who called whom, and it's temporary. The lexical environment is about which variables a piece of code can see. It's decided by where the code was written, and it can outlive the call.
Every environment is a record of bindings plus a link to its outer environment. Those links form the scope chain, and they have nothing to do with the order of calls.
function makeCounter() {
let count = 0; // lives in makeCounter's environment
return () => ++count; // outer link → makeCounter's environment
}
const next = makeCounter(); // makeCounter's frame is popped here
next(); // 1
next(); // 2 (the environment is still alive)
By the time I call next(), makeCounter is gone from the stack. But its environment isn't garbage-collected, because the arrow function still holds a reference to it. That's the whole mechanism behind closures.
The way I picture it is that stack frames come and go, and environments stay alive as long as something can still reach them. That also explains the memory side: a closure that stays alive keeps its outer variables alive too.
AdvancedWhat happens to the call stack when an async function hits await?
So the function's frame is popped off the stack. The engine saves its state (local variables and where it paused) on the heap and returns a pending promise to the caller. When the awaited promise settles, a microtask puts the function back on the stack right where it left off.
async function load() {
console.log("2: load start");
await null;
console.log("4: load resumed");
}
console.log("1: before");
load();
console.log("3: after");
// 1: before
// 2: load start
// 3: after
// 4: load resumed
Everything up to the first await runs synchronously. That's why "2" comes before "3." Then load leaves the stack, the caller continues, and once the stack is empty the microtask queue resumes load.
This has a practical side for debugging. After an await, the real stack no longer includes the caller. DevTools and Node show "async stack traces" by stitching the history back together. And a try/catch around load() without await won't catch its errors, because by the time it throws, the try block is long gone.
Interviewers look for: "code before the first await runs synchronously." The usual follow-up is a mixed promise and setTimeout ordering puzzle.
AdvancedWhy can value instanceof Array be false for a real array?
So that happens when the array comes from a different realm. Each realm, meaning each window, iframe, worker, or Node vm context, has its own global object and its own copies of the built-ins. instanceof checks the prototype chain against this realm's Array.prototype, and an iframe's array has a different one.
const frame = document.createElement("iframe");
document.body.append(frame);
const OtherArray = frame.contentWindow.Array;
const arr = new OtherArray(1, 2, 3);
arr instanceof Array; // false (different Array.prototype)
Array.isArray(arr); // true (checks the internal "is array" flag)
Object.prototype.toString.call(arr); // "[object Array]"
I ran into this with a payment widget embedded in an iframe that passed data up to the host page. Validation that used instanceof rejected perfectly valid arrays. It's the same idea with instanceof Error across realms, which is why Error.isError was proposed.
So in library-style code I use brand checks like Array.isArray, or duck typing, instead of instanceof. Also, postMessage and structuredClone recreate objects in the receiving realm, so the problem mostly appears with direct object sharing between same-origin frames.
6. Hoisting & the Temporal Dead Zone
BasicWhat is hoisting? Does the engine actually move code?
So no, nothing moves. Hoisting is the observable effect of the creation phase. Before a scope runs, the engine registers every declaration in it, so the names exist from the top of the scope. What differs is what value each one starts with.
sayHi(); // "hi" (function declarations are fully ready)
console.log(count); // undefined (var exists, value not assigned yet)
console.log(total); // ReferenceError: Cannot access 'total' before initialization
function sayHi() { console.log("hi"); }
var count = 1;
let total = 2;
So there are really three behaviours:
function declaration → hoisted with its body (callable early)
var → hoisted, initialized to undefined
let / const / class → hoisted but uninitialized (the TDZ)
The practical upside is that I can put helper functions at the bottom of a file and the main logic at the top, which reads nicely. The downside of var is that reading it early gives you a silent undefined instead of an error, which is why I don't use it.
BasicPredict the output.
var x = 1;
function f() {
console.log(x);
var x = 2;
}
f();
So it logs undefined, not 1.
The var x inside f is hoisted to the top of f's scope. So during f's creation phase a local x is created with the value undefined, and it shadows the global one for the whole function, including the line before the declaration. The engine effectively sees this:
function f() {
var x; // local, undefined
console.log(x); // reads the local one
x = 2;
}
If I change it to let x = 2, it throws a ReferenceError instead. The local x still exists and still shadows the global one, but it's in the temporal dead zone.
This is a real bug pattern in older codebases. Someone adds a var with the same name further down a long function, and a read at the top silently breaks.
Interviewers look for: the word "shadowing" and knowing that the let version throws rather than printing 1.
IntermediateWhat is the Temporal Dead Zone, and why does it exist?
So the TDZ is the period from the start of a scope until the line where a let, const or class is declared. During that time the binding exists but is uninitialized, so any access throws a ReferenceError. "Temporal" means it's about time, not position in the code.
{
// TDZ for `size` starts here
const read = () => size; // fine, only defines a function
// read(); // would throw, still in the TDZ
let size = 10; // TDZ ends
read(); // 10, same code, now it works
}
typeof undeclaredThing; // "undefined"
{
typeof later; // ReferenceError, even typeof isn't safe in the TDZ
let later = 1;
}
The read example shows why it's temporal. The function body mentions size above the declaration, and that's fine. What matters is whether it runs before the declaration line.
It exists mainly for const. If const were initialized to undefined like var, you could see it hold two different values, which breaks the whole point of a constant. It also turns silent undefined bugs into loud errors, which I consider a feature.
IntermediateWhy does one of these calls work and the other two fail differently?
declared(); // ?
expressed(); // ?
arrowed(); // ?
function declared() { return "ok"; }
var expressed = function () { return "ok"; };
const arrowed = () => "ok";
So declared() works, expressed() throws TypeError: expressed is not a function, and arrowed() throws ReferenceError: Cannot access 'arrowed' before initialization.
The key is that only a function declaration gets hoisted with its body. The other two are just variables that happen to be assigned a function later.
// after the creation phase:
declared → <function> callable
expressed → undefined calling undefined gives a TypeError
arrowed → <uninitialized> touching it in the TDZ gives a ReferenceError
The type of error tells you exactly which situation you're in, which is really useful when debugging. "Not a function" usually means a var read too early or a wrong import. "Before initialization" means a TDZ problem, often from a circular import between two ES modules.
In practice, most of our code uses const plus arrow functions, so the rule I follow is simple: define it before you call it at the top level.
IntermediateAre let and const hoisted? How would you prove it?
So yes, they are hoisted. They just aren't initialized. A lot of people say "let isn't hoisted," and I can disprove that in three lines:
let color = "red";
{
console.log(color); // ReferenceError, not "red"
let color = "blue";
}
If the inner let color weren't hoisted, the console.log would walk up the scope chain and find the outer "red". Instead it throws. That means the engine already knows there's a color in the block when it starts running it. The binding was created at the top of the block and is sitting in the TDZ.
The same thing happens with parameters, which surprises people:
function f(a = b, b = 2) { return a; }
f(); // ReferenceError: b is in its TDZ when a's default runs
f(1); // 1 (a's default never runs)
Default parameters are evaluated left to right, and each one is only initialized when its turn comes. So in short: hoisted means the name is registered for the whole scope, and initialized means you can read it. var gets both right away. let and const get the first immediately and the second at the declaration line.
Interviewers look for: the shadowing proof. Saying "let isn't hoisted" is a common wrong answer that interviewers check for.
IntermediateAre classes hoisted? Why does this fail?
const admin = new Admin(); // ?
class Admin extends User {}
class User {}
So class declarations behave like let: hoisted but in the TDZ. The first line throws ReferenceError: Cannot access 'Admin' before initialization.
Even if I move the new call to the bottom, this still fails, because class Admin extends User evaluates User immediately when the class is defined. At that moment User is still in its TDZ:
class Admin extends User {} // ReferenceError: Cannot access 'User'...
class User {}
// fixed: define the parent first
class User {}
class Admin extends User {}
const admin = new Admin();
Classes were designed this way on purpose. The extends clause and computed method names are real expressions that run in order, so letting you use a class before its definition had run would be confusing.
Where this bites in practice is circular imports. If admin.js imports User from user.js, and user.js imports something from admin.js, one module can run before the other's class is initialized. You get this exact error at load time. The fix is usually to break the cycle, for example by moving a shared piece into a third module.
AdvancedPredict the output when a function and a var share a name.
console.log(typeof foo);
var foo = "bar";
function foo() {}
console.log(typeof foo);
So it prints "function" and then "string".
In the creation phase, the function declaration wins over the var. A var declaration never overwrites an existing binding with undefined. It just says "make sure this name exists." So when execution starts, foo is the function.
Then execution runs top to bottom. The first log sees the function. The line var foo = "bar" is really just the assignment foo = "bar" at runtime, so after it runs, foo is a string. The function declaration line does nothing at execution time, because it was already handled.
// what the engine effectively does
function foo() {} // creation: foo = function
// (var foo: already exists, ignored)
console.log(typeof foo); // "function"
foo = "bar";
console.log(typeof foo); // "string"
With let foo instead of var, this is a SyntaxError: identifier already declared. That's one of the nice things about let. The whole class of puzzle can't happen, and the linter flags it before it ever runs.
7. The Scope Chain
BasicWhat is the scope chain, and how does the engine resolve a variable?
So when code reads a variable, the engine looks in the current scope first. If it isn't there, it follows the outer link to the enclosing scope, and so on until it reaches the global scope. If it's still not found, you get a ReferenceError. That chain of outer links is the scope chain.
const app = "shop"; // global
function checkout() {
const currency = "USD"; // checkout scope
function total(items) {
const tax = 0.2; // total scope
return `${app}: ${items * (1 + tax)} ${currency}`;
// tax found in total
// currency found one level up
// app found in global
}
return total(10);
}
checkout(); // "shop: 12 USD"
tax; // ReferenceError: the chain only goes outward, never inward
The lookup only goes outward, which is why outer code can't see inner variables. It stops at the first match, which is what makes shadowing possible.
And the chain is built from where the function is written, not where it's called. That's lexical scope, and it's why closures work the way they do.
BasicWhat's the difference between lexical and dynamic scope? Which one does JavaScript use?
So JavaScript uses lexical scope, also called static scope. A function's outer variables are determined by where it's defined in the source. With dynamic scope, they'd depend on who called it.
const level = "global";
function show() {
console.log(level);
}
function run() {
const level = "local";
show();
}
run(); // "global"
Under dynamic scope this would print "local", because show is being called from inside run. But show was written at the top level, so its outer scope is the global one, forever.
The nice part is that you can work out what a variable refers to just by reading the code. Tools can do it too: bundlers can rename variables safely, and editors can "go to definition."
The one thing in JavaScript that feels dynamic is this. It's decided by the call site, not the definition. People mix the two up, and it's a big reason arrow functions exist. They make this lexical too.
Interviewers look for: the contrast with this. They often follow up with "so what does this depend on?"
BasicWhat's the difference between block scope and function scope? Show me a bug caused by the difference.
So var is function-scoped. It ignores { } blocks and belongs to the whole enclosing function. let and const are block-scoped, so they only exist inside the nearest { }, like an if, a loop or a bare block.
function applyDiscount(price, isMember) {
if (isMember) {
var discount = 0.1;
}
return price * (1 - discount);
}
applyDiscount(100, true); // 90
applyDiscount(100, false); // NaN (discount is undefined, not an error)
Here, because of var, discount exists in the whole function even when the if never runs. Its value is just undefined, and 1 - undefined is NaN. No error, just wrong prices. If I'd used let, that last line would throw a ReferenceError and I'd catch the bug immediately.
The other classic var bug is the loop counter that leaks and gets shared by callbacks. That's the closure-in-a-loop question.
So in practice I use const by default, let when I need to reassign, and never var. Block scope keeps variables as close as possible to where they're used.
IntermediateWhat is shadowing, and when is it illegal?
So shadowing is declaring a variable in an inner scope with the same name as one in an outer scope. Inside that inner scope, the inner one hides the outer one. Usually it's legal, but there's a case where it's a SyntaxError.
const user = "outer";
function greet() {
const user = "inner"; // legal shadowing
return user; // "inner"
}
let a = 1;
{
let a = 2; // legal: new block-scoped a
}
let b = 1;
{
var b = 2; // SyntaxError: Identifier 'b' has already been declared
}
The illegal case is when a var in a block tries to shadow a let. var doesn't stay in the block. It hoists up to the enclosing function or global scope, where it collides with the let b that's already there. So it isn't really shadowing, it's a redeclaration.
In practice I avoid shadowing on purpose. It's the source of confusing bugs, like shadowing a props or error variable inside a catch or a callback and then updating the wrong one. The ESLint rule no-shadow catches it, and I usually turn it on.
IntermediateHow can you accidentally pollute the global scope, and why does it matter?
So there are three ways I've seen. var and function declarations at the top level of a classic script become properties of window. Assigning to an undeclared variable in sloppy mode creates a global. And this in a plain function call is window in sloppy mode.
// classic <script>, not a module, not strict
var appName = "shop";
window.appName; // "shop" (var leaks onto window)
let version = 2;
window.version; // undefined (let is global but not on window)
function save() {
result = 42; // typo or missing let: creates window.result
}
var name = [1, 2];
typeof name; // "string"! window.name is a built-in that coerces to string
That name one is my favourite gotcha. Because window.name already exists as a special property, var name doesn't create a new variable. The assignment goes through its setter, which converts the array to "1,2".
It matters because every script on the page shares that global object, including analytics, third-party widgets and other bundles. Two scripts using the same global name will overwrite each other.
The fix is structural: use ES modules or a bundler, so each file gets its own scope, and use strict mode, which is automatic in modules and classes, so the typo case throws a ReferenceError instead of creating a global.
IntermediateHow is scope different inside an ES module compared to a classic script?
So each module has its own top-level scope. Nothing I declare leaks to window or into other modules unless I export it. On top of that, modules are always in strict mode, and top-level this is undefined.
<!-- classic scripts share one global scope -->
<script>var count = 1;</script>
<script>console.log(count);</script> <!-- 1 -->
<!-- modules don't -->
<script type="module">const total = 1;</script>
<script type="module">console.log(typeof total);</script> <!-- "undefined" -->
<script type="module">
console.log(this); // undefined
oops = 5; // ReferenceError (strict mode)
const data = await fetch("/api").then(r => r.json()); // top-level await is allowed
</script>
There are some other differences I'd mention. A module runs once no matter how many times it's imported, so its top-level state is effectively a singleton. Imports are live, read-only bindings. And module scripts are deferred by default.
That singleton part has practical consequences. I've used a module-level Map as a simple cache, but on a server running Next.js that same module state is shared across all users' requests. So I never put per-user data at module scope on the server.
AdvancedWhy do eval and with hurt performance and tooling?
So engines and tools rely on lexical scope being static. By reading the code, they can know which declaration every variable name refers to. eval and with are the two features that break that guarantee, because they can change what's in scope at runtime.
function withEval(code) {
var x = 1;
eval(code); // could be "var x = 2" or "var y = 3"
return x; // the engine can't know which x this is ahead of time
}
function withWith(obj) {
with (obj) {
return total; // obj.total or an outer total? only known at runtime
}
}
When a function contains a direct eval, V8 has to keep every variable in scope around, because the evaluated string might reference any of them. It can't optimize lookups or throw away unused variables. Minifiers also stop renaming variables in that function, because the string might mention them by name. with is simply banned in strict mode, so it can't appear in modules or classes.
There's also "indirect eval," (0, eval)(code), which runs in the global scope and doesn't touch local variables. It's somewhat less harmful, but it's still a code-injection risk, and it's blocked by a strict Content Security Policy.
So in practice I never use either. For dynamic behaviour I use JSON.parse for data, new Function in the rare sandbox case, or a proper lookup map.
8. Closures
BasicWhat is a closure? Give me an example.
So a closure is a function that remembers the variables from the scope where it was created, even after that outer function has returned. Every function in JavaScript has one. It only gets interesting when the inner function outlives the outer call.
function createCounter() {
let count = 0;
return {
increment: () => ++count,
get: () => count,
};
}
const counterA = createCounter();
const counterB = createCounter();
counterA.increment(); // 1
counterA.increment(); // 2
counterB.increment(); // 1 (separate call, separate count)
counterA.get(); // 2
Here, what happens is that createCounter finishes and its frame leaves the stack. But increment and get still reference its environment, so count stays alive. Each call to createCounter creates a new environment, which is why A and B don't share state. And both functions returned from the same call share the same count. They hold a live reference, not a snapshot.
I use closures constantly without thinking about them: event handlers reading component state, debounce, factories, and every React hook.
Interviewers look for: "live reference, not a copy." The follow-up is almost always the var-in-a-loop question.
IntermediateWhat does this print, and how would you fix it?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
So it prints 3, 3, 3.
There's only one i, because var is function-scoped. All three arrow functions close over that same variable. The loop runs to completion first, leaving i at 3, and only then do the timers fire and read it.
The modern fix is just changing var to let:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 0, 1, 2
}
That works because a let in a for header gets a fresh binding for each iteration, and the spec copies the value forward. So each callback closes over its own i.
Before ES2015 the fix was an IIFE to create a new scope per iteration, or passing the value as setTimeout's third argument:
for (var i = 0; i < 3; i++) {
(function (j) { setTimeout(() => console.log(j), 100); })(i);
}
for (var i = 0; i < 3; i++) setTimeout(console.log, 100, i);
I've seen this in real code too, not only puzzles. We had a loop attaching click handlers to a list of buttons, and every button opened the last item.
IntermediateHow would you use a closure for private state? How does that compare to #private fields?
So the classic pattern is a factory function. The data lives in local variables, and I return only the functions allowed to touch it. There is literally no reference path from outside to the variable.
function createWallet(initial) {
let balance = initial; // private
const history = [];
return {
deposit(amount) {
if (amount <= 0) throw new Error("Invalid amount");
balance += amount;
history.push({ type: "deposit", amount });
},
getBalance: () => balance,
};
}
const w = createWallet(100);
w.deposit(50);
w.getBalance(); // 150
w.balance; // undefined, can't reach it
The modern alternative is class private fields:
class Wallet {
#balance;
constructor(initial) { this.#balance = initial; }
getBalance() { return this.#balance; }
}
The trade-offs I think about: the closure version creates new function objects for every instance, while class methods are shared on the prototype. That matters if you have thousands of instances. Closures also have no this problems, so I can pass w.getBalance straight into a callback. Classes give me instanceof, inheritance, and nicer DevTools output.
So for a small module-style API I'd use the closure. For many instances or a domain model, I'd use a class with #fields.
IntermediateWrite a once function.
So once(fn) returns a wrapper that calls fn the first time and returns the cached result on every later call. The "has it run?" flag and the result live in the closure.
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
fn = null; // let the original be garbage-collected
}
return result;
};
}
const init = once(() => {
console.log("connecting...");
return { connected: true };
});
init(); // logs "connecting...", returns { connected: true }
init(); // returns the same object, no log
There are a few details I'd point out. I set called = true before calling fn, so if fn somehow triggers the wrapper again, it doesn't run twice. I use a regular function and fn.apply(this, args), so it still works as a method with the right this. And nulling out fn releases anything it captured.
An open question I'd raise is what should happen if fn throws. As written, it's marked as called, and later calls return undefined. For something like initializing a connection, I'd probably reset called in a catch so it can retry.
Interviewers look for: preserving this and arguments, and caching the result. A frequent follow-up is making it work for async functions by caching the promise.
IntermediateCan closures cause memory leaks? Give me a real example.
So a closure keeps its outer variables alive for as long as the closure itself is reachable. That's not a leak by itself. The leak happens when something long-lived, like a global, the DOM, a timer or an event emitter, holds on to a closure that holds on to something big.
function attachReport(button) {
const bigReport = new Array(1_000_000).fill("row"); // large data
button.addEventListener("click", () => {
console.log("rows:", bigReport.length);
});
}
// Even if the button is removed from the page, as long as something
// still references it, the listener keeps bigReport alive.
setInterval(() => {
updateChart(bigData); // an interval never cleared keeps bigData forever
}, 1000);
There's a subtler V8 detail too. All closures created in the same scope share one context object. So if one closure uses bigReport and another closure from the same function stays alive, bigReport can be kept even though the surviving closure never mentions it.
In a single-page app I found exactly this pattern. A dashboard subscribed to a WebSocket in a component and never unsubscribed, so every visit to the page stacked another listener holding old chart data. The heap snapshot in DevTools showed it under "Detached" nodes and closure contexts.
So in practice: always clean up in useEffect return functions, use AbortController to remove listeners, clear intervals, and copy just the small value you need into a closure instead of capturing the whole object.
IntermediateWhat is a stale closure in React, and how do you fix one?
So a stale closure is a function that captured a variable from an old render and keeps using that old value. In React each render is a new function call with its own count, so a callback created in render 1 sees render 1's values forever.
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count is always 0 here
}, 1000);
return () => clearInterval(id);
}, []); // runs once, so it captures render 1
return <p>{count}</p>; // shows 1, then stays at 1
}
The effect ran once, so the interval callback closes over count from the first render, which is 0. Every tick sets 0 + 1.
There are three fixes, and I pick based on the situation:
// 1. Functional update: doesn't need to read count at all (best here)
setCount(c => c + 1);
// 2. List the dependency: effect re-subscribes when count changes
useEffect(() => { /* ... */ }, [count]);
// 3. A ref for the "latest value" without re-running the effect
const countRef = useRef(count);
countRef.current = count;
The ESLint rule react-hooks/exhaustive-deps catches most of these. When someone disables it on a line, that's usually where I go looking for this bug.
AdvancedImplement debounce, and explain what the closure is doing.
So debounce delays calling a function until calls have stopped for a given amount of time. The classic use is a search box: I don't want a request on every keystroke, only when the user pauses. The closure holds the timer ID between calls.
function debounce(fn, wait) {
let timerId; // shared across every call of the wrapper
function debounced(...args) {
clearTimeout(timerId); // cancel the previous pending call
timerId = setTimeout(() => {
fn.apply(this, args); // arrow keeps the wrapper's this
}, wait);
}
debounced.cancel = () => clearTimeout(timerId);
return debounced;
}
const search = debounce((q) => fetchResults(q), 300);
input.addEventListener("input", (e) => search(e.target.value));
Every keystroke calls debounced, which reads and overwrites the same timerId in the closure. So only the last call within 300ms survives. The arrow function inside setTimeout is deliberate: it keeps this and args from that last call.
In React there's one gotcha. If I write const search = debounce(...) directly in the component body, a new debounced function with a new closure is created every render, so it never actually debounces. I wrap it in useMemo or useRef, and call cancel in the cleanup so it can't fire after unmount.
Interviewers look for: the React re-creation bug. Common extensions are a leading-edge option, maxWait, or writing throttle next to it.
9. Functions in Depth
BasicWhat's the difference between a function declaration and a function expression?
So a declaration is a statement that starts with function name(), and it's hoisted with its body. An expression is a function used as a value, usually assigned to a variable, and it only exists once that line has run.
// declaration: hoisted, callable anywhere in the scope
function formatPrice(n) { return `$${n.toFixed(2)}`; }
// expression: only available after this line
const formatDate = function (d) { return d.toISOString(); };
// named function expression: the name is visible only inside
const factorial = function fact(n) {
return n <= 1 ? 1 : n * fact(n - 1);
};
typeof fact; // "undefined" outside
Named function expressions are underrated. The inner name gives you safe self-reference for recursion, even if the outer variable gets reassigned. It also shows up in stack traces. Modern engines infer names from the variable too, so formatDate.name is "formatDate".
In practice I use declarations for top-level helpers in a module, because they read well and can live at the bottom of the file. I use arrow expressions for callbacks and anything small and inline.
BasicHow are arrow functions different from regular functions, beyond the syntax?
So the big one is that arrows don't have their own this. They use the this of the surrounding scope. But there are several other differences that follow from that design:
const Arrow = () => {};
new Arrow(); // TypeError: Arrow is not a constructor
Arrow.prototype; // undefined
const f = () => arguments; // no own `arguments`: uses the outer one
// (or a ReferenceError at module top level)
// returning an object literal needs parentheses
const make = () => ({ id: 1 }); // returns { id: 1 }
const oops = () => { id: 1 }; // block with a label, returns undefined
So, summing up: no this, no arguments, no new, no prototype, no super of their own, and they can't be generators.
Where the this difference really helps is callbacks inside methods:
class Poller {
count = 0;
start() {
setInterval(() => this.count++, 1000); // this = the Poller instance
}
}
Where it hurts is object methods. { total: () => this.items.length } won't see the object. So I use arrows for callbacks and short functions, and method shorthand for anything that needs its own this.
BasicWhat do "first-class functions" and "higher-order functions" mean? Give me real examples.
So first-class means functions are ordinary values: I can store them in variables, pass them as arguments, return them, and put them in objects. A higher-order function is a function that takes a function or returns one, which is only possible because functions are first-class.
// takes a function
const prices = [10, 25, 40];
const withTax = prices.map((p) => p * 1.2);
const expensive = prices.filter((p) => p > 20);
// returns a function
const multiplier = (factor) => (n) => n * factor;
const double = multiplier(2);
double(21); // 42
// stored in an object: a strategy map instead of a switch
const sorters = {
price: (a, b) => a.price - b.price,
name: (a, b) => a.name.localeCompare(b.name),
};
products.toSorted(sorters[sortKey]);
That strategy-map pattern is one I use a lot. In a product listing, the sort dropdown value maps straight to a comparator, so adding a new sort option is one line instead of another case.
Other higher-order functions I use every day are debounce, memoize, middleware in Express, and React's older higher-order component pattern. Custom hooks have mostly replaced that last one now.
IntermediateWhat's the difference between arguments and rest parameters?
So arguments is the old, array-like object that every regular function gets automatically. Rest parameters (...args) give me a real array of just the arguments I didn't name. I use rest in all new code.
function oldWay() {
arguments.length; // works
arguments.map; // undefined (not a real array)
return Array.from(arguments).map(String);
}
function newWay(first, ...rest) {
rest.map(String); // real array
return rest; // excludes `first`
}
newWay(1, 2, 3); // [2, 3]
const arrow = () => arguments; // arrows have no own arguments
There's also a weird legacy behaviour. In sloppy mode, with simple parameters, arguments is linked to the named parameters:
function sloppy(a) { arguments[0] = 99; return a; }
sloppy(1); // 99 (changing arguments changed a)
function strict(a) { "use strict"; arguments[0] = 99; return a; }
strict(1); // 1
That link also disappears as soon as a function has default, rest or destructured parameters. So rest parameters are clearer and self-documenting in the signature, and they work in arrows. The only time I touch arguments now is when reading legacy code.
IntermediateHow do default parameters behave? Predict these calls.
function price(base, tax = base * 0.2, label = "item") {
return `${label}: ${base + tax}`;
}
price(100); // ?
price(100, 0); // ?
price(100, null); // ?
price(100, undefined, ""); // ?
So the rule is that a default only kicks in when the argument is undefined, meaning missing or explicitly undefined. Any other value, including 0, null and "", is used as given.
price(100); // "item: 120" (tax = 20)
price(100, 0); // "item: 100" (0 is a real value)
price(100, null); // "item: 100" (null becomes 0 in +)
price(100, undefined, ""); // ": 120" (tax defaults, label is "")
A few other things I'd mention. Defaults are evaluated at call time, every time, and they can use earlier parameters, like tax using base. That means a default like items = [] gives you a fresh array per call. Python developers get caught by the opposite behaviour.
The null case is where I've seen real bugs. A JSON API returned "discount": null, we passed it straight in, and the default never applied. So at API boundaries I normalize with discount ?? 0 before calling, rather than relying on the parameter default.
Interviewers look for: "only undefined triggers the default," plus call-time evaluation. They may ask how f.length changes with defaults.
IntermediateWhat is an IIFE, and do you still need one today?
So an IIFE is an immediately invoked function expression: you define a function and call it right away. Before block scope and modules, it was the only way to create a private scope, so every library was wrapped in one.
// classic use: private scope in a plain script
(function () {
var secret = "not on window";
window.MyLib = { version: "1.0" };
})();
// the parentheses make it an expression, not a declaration
function () {}(); // SyntaxError
Today, modules and let/const blocks cover most of that. But I still reach for an IIFE in a few places:
// 1. async code where top-level await isn't available (CommonJS, some scripts)
(async () => {
const config = await loadConfig();
start(config);
})();
// 2. computing a const with a bit of logic
const theme = (() => {
const saved = localStorage.getItem("theme");
if (saved) return saved;
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
})();
That second one keeps the value const instead of using let and reassigning inside if branches. Also, if a line starts with ( and you don't use semicolons, it can join with the previous line. That's why you sometimes see ;(() => {})().
IntermediateWhat is a pure function? Is this one pure?
function getTopScores(scores) {
return scores.sort((a, b) => b - a).slice(0, 3);
}
So a pure function always returns the same output for the same input and has no side effects: no mutation of its inputs, no network, no logging, no reading globals that can change. This one looks pure but isn't, because sort mutates the caller's array.
const all = [40, 90, 70, 100];
getTopScores(all); // [100, 90, 70]
console.log(all); // [100, 90, 70, 40] (the caller's data got reordered)
// pure version
function getTopScores(scores) {
return scores.toSorted((a, b) => b - a).slice(0, 3);
// or [...scores].sort(...)
}
Pure functions matter to me because they're trivial to test (no mocks, just input and output), safe to memoize, and safe for React, which may call your render function more than once. In development, Strict Mode deliberately double-invokes it to expose exactly this kind of impurity.
ES2023 added non-mutating versions of the dangerous array methods: toSorted, toReversed, toSpliced and with. I default to those.
In practice, an app can't be all pure. The goal is a pure core, like calculations, reducers and formatting, with side effects pushed to the edges: event handlers, effects, and API layers.
AdvancedWhat does a function's length tell you, and why does it matter for currying?
So fn.length is the number of parameters the function declares before the first default or rest parameter. Generic helpers like curry use it to know how many arguments to collect before calling.
(function (a, b, c) {}).length; // 3
(function (a, b = 1, c) {}).length; // 1 (stops at the first default)
(function (...args) {}).length; // 0
(function ({ a, b }) {}).length; // 1 (a destructured param counts as one)
function curry(fn) {
return function curried(...args) {
return args.length >= fn.length
? fn.apply(this, args)
: (...more) => curried.apply(this, [...args, ...more]);
};
}
const volume = curry((l, w, h) => l * w * h);
volume(2)(3)(4); // 24
volume(2, 3)(4); // 24
Here, curried keeps collecting arguments through closures until it has fn.length of them, and then calls the original.
The gotcha is exactly the length rules. If someone curries (a, b = 2) => a + b, length is 1, so it fires after one argument and the default is used. With a rest-parameter function it fires immediately. So a robust curry accepts an explicit arity as an optional second argument.
In real code I rarely curry by hand. But partial application with a closure or bind is common, like const logError = log.bind(null, "error").
10. this & Binding
BasicHow is the value of this decided?
So for regular functions, this is decided by how the function is called, not where it's defined. There are four rules I check. Arrow functions are the exception: they skip all four and take this from the surrounding scope.
function show() { return this; }
const obj = { show };
// 1. new binding: this = the brand-new object
new show(); // show {}
// 2. explicit binding: call / apply / bind
show.call({ id: 1 }); // { id: 1 }
// 3. implicit binding: called as obj.method()
obj.show(); // obj
// 4. default binding: plain call
show(); // undefined in strict mode / modules,
// globalThis in sloppy mode
My mental shortcut is to look at the call site. Was it called with new? Then it's the new object. With call, apply or bind? Then it's what I passed. Is there a dot before the call? Then it's the thing before the dot. None of those? Then it's undefined, or the global object if the code is sloppy.
Almost every this bug I've debugged comes down to rule 3 turning into rule 4, where a method gets called without its dot. That's the "lost this" problem.
Interviewers look for: "the call site decides," and that arrows are outside the four rules. The next question is usually precedence.
IntermediateWhen several binding rules apply at once, which one wins?
So the order is new, then explicit (bind, call, apply), then implicit (method call), then default. Arrow functions sit outside this entirely, because nothing can change their this.
function Person(name) { this.name = name; }
// explicit beats implicit
const obj = { name: "obj", get() { return this.name; } };
obj.get.call({ name: "call" }); // "call"
// bind beats call: a bound function ignores later call/apply
const bound = obj.get.bind({ name: "bound" });
bound.call({ name: "call" }); // "bound"
// new beats bind
const BoundPerson = Person.bind({ name: "ignored" });
const p = new BoundPerson("Ana");
p.name; // "Ana" (this is the new object)
// arrows ignore everything
const arrow = () => this;
arrow.call({ x: 1 }); // outer this, not { x: 1 }
The surprising one is new beating bind. The spec made bound functions still usable as constructors: the bound this is thrown away, but any pre-filled arguments are kept. And you can't re-bind a bound function. A second bind can add arguments, but the this stays from the first.
In practice I almost never combine these on purpose. But knowing that bind is "sticky" explains why passing an already-bound handler to a library that calls it with .call(element) still gives me my component instance.
IntermediateThis method works when called directly, but breaks as a callback. Why, and how do you fix it?
class Uploader {
constructor() { this.files = []; }
add(file) { this.files.push(file); }
}
const up = new Uploader();
up.add("a.png"); // works
["b.png", "c.png"].forEach(up.add); // TypeError: Cannot read properties of undefined
button.addEventListener("click", up.add); // this is the button
So passing up.add passes just the function. The up. part, which is what set this, is gone. forEach calls it as a plain function, so we get default binding, and class bodies are always strict, so this is undefined. addEventListener calls it with this set to the element instead.
There are three fixes, and I choose based on context:
// 1. arrow wrapper at the call site: explicit and easy to read
files.forEach((f) => up.add(f));
// 2. bind once
button.addEventListener("click", up.add.bind(up));
// 3. class field arrow: bound for good, one copy per instance
class Uploader {
files = [];
add = (file) => { this.files.push(file); };
}
// bonus: many array methods take a thisArg
files.forEach(up.add, up);
One gotcha with option 2 in event listeners: bind creates a new function each time, so removeEventListener(up.add.bind(up)) won't remove anything. I store the bound function once, or use an AbortController signal to remove the listener.
IntermediateWhy is using an arrow function as an object method usually a bug?
const cart = {
items: ["book", "pen"],
count: () => this.items.length,
countLater() {
setTimeout(function () {
console.log(this.items.length);
}, 0);
},
};
cart.count(); // TypeError: Cannot read properties of undefined
cart.countLater(); // TypeError too, for the opposite reason
So an arrow takes this from where it's written. An object literal doesn't create a scope, so the arrow in count gets the this of the surrounding module or script. In an ES module that's undefined, hence the error. People expect the object because it looks like it's inside it.
countLater has the opposite problem. The method itself has the right this, but the regular function passed to setTimeout is called without a dot, so it gets its own, different this.
The fix is to use each kind where it fits:
const cart = {
items: ["book", "pen"],
count() { return this.items.length; }, // method shorthand
countLater() {
setTimeout(() => console.log(this.items.length), 0); // arrow inherits this
},
};
So my rule is: method shorthand for the method itself, and arrows for callbacks inside it. That combination does the right thing in almost every case.
IntermediateWhat's the difference between call, apply and bind, and when would you actually use each?
So all three set this explicitly. call and apply invoke the function immediately. They only differ in how you pass arguments: call takes a list, and apply takes an array. bind doesn't call anything. It returns a new function with this, and optionally some arguments, fixed in place.
function greet(greeting, punct) { return `${greeting}, ${this.name}${punct}`; }
const user = { name: "Ana" };
greet.call(user, "Hi", "!"); // "Hi, Ana!"
greet.apply(user, ["Hi", "!"]); // "Hi, Ana!"
const hiAna = greet.bind(user, "Hi");
hiAna("?"); // "Hi, Ana?"
Here's where I actually use each one today:
// call: borrowing a method, or calling a parent implementation
Object.prototype.hasOwnProperty.call(obj, "key"); // safe even if obj overrides it
// (Object.hasOwn is the modern way)
// apply: mostly replaced by spread
Math.max.apply(null, nums); // old
Math.max(...nums); // now
// bind: fixing this for a callback, or partial application
el.addEventListener("click", this.onClick.bind(this));
const logWarn = console.log.bind(console, "[warn]");
So spread made apply mostly redundant. call still appears in library and polyfill code. bind is the one I use in app code, although arrow functions and class-field arrows have replaced most of those uses too.
Interviewers look for: "call and apply invoke, bind returns a function." The follow-up is often "implement bind yourself."
AdvancedPredict each line. Assume this runs in an ES module.
const obj = {
name: "obj",
regular() { return this?.name; },
arrow: () => this?.name,
nested() {
const inner = () => this.name;
return inner();
},
nestedFn() {
function inner() { return this?.name; }
return inner();
},
};
const { regular } = obj;
console.log(obj.regular()); // ?
console.log(regular()); // ?
console.log(obj.arrow()); // ?
console.log(obj.nested()); // ?
console.log(obj.nestedFn()); // ?
console.log((obj.regular)()); // ?
So line by line: "obj", undefined, undefined, "obj", undefined, "obj".
obj.regular() // "obj" implicit binding, there's a dot
regular() // undefined destructuring lost the dot, strict so this = undefined
obj.arrow() // undefined arrow uses the module's top-level this, which is undefined
obj.nested() // "obj" the arrow inherits nested's this, which is obj
obj.nestedFn() // undefined inner is a plain call, so it gets its own this
(obj.regular)() // "obj" grouping parentheses keep the reference, unlike (0, obj.regular)()
The one people get wrong is nestedFn. Being written inside a method doesn't matter for a regular function. Only the call site does, and inner() has no dot.
I'd also mention why I said "module." In a classic browser script, top-level this is window, and window.name exists (it's usually ""). So the arrow line would print an empty string instead of undefined. The execution environment changes the answer, which is why I state the assumption first.
AdvancedImplement Function.prototype.bind.
So bind needs to return a function that calls the original with a fixed this and some pre-filled arguments. The simple version is a closure plus apply. The full version also has to handle being called with new, because new beats bind.
Function.prototype.myBind = function (boundThis, ...preset) {
const target = this;
if (typeof target !== "function") {
throw new TypeError("myBind must be called on a function");
}
function bound(...args) {
// called with new: ignore boundThis and let `this` be the new instance
const isNew = new.target !== undefined;
return target.apply(isNew ? this : boundThis, [...preset, ...args]);
}
// so instances created with `new bound()` inherit from target.prototype
if (target.prototype) {
bound.prototype = Object.create(target.prototype);
}
return bound;
};
function Point(x, y) { this.x = x; this.y = y; }
const AtOrigin = Point.myBind(null, 0);
const p = new AtOrigin(5);
p; // Point { x: 0, y: 5 }
p instanceof Point; // true
Walking through it: this inside myBind is the function being bound, because it's called as fn.myBind(...). The closure keeps target, boundThis and preset. When bound is called normally, it applies with boundThis. When it's called with new, new.target is set, so I pass the freshly created this instead. The prototype line is what makes instanceof work.
There are some differences from the native one I'd mention: the real bind also sets name to "bound Point" and length to the remaining parameter count, and native bound functions don't have a prototype property at all. In an interview I'd write the simple version first, then add the new handling when asked.
Interviewers look for: the new case. Most candidates stop at fn.apply(ctx, args). Mentioning new.target is a strong senior signal.
11. Objects in Depth
BasicWhy is { a: 1 } === { a: 1 } false, and how would you actually compare two objects?
So objects are compared by reference, not by what's inside them. Each { a: 1 } literal creates a brand-new object in memory, so === is really asking "are these the same object?", and they're not.
Let me show you the cases I'd separate:
const a = { a: 1 };
const b = a;
a === b; // true — same reference
a === { a: 1 }; // false — same contents, different object
// shallow compare — what React.memo does for props
function shallowEqual(x, y) {
if (Object.is(x, y)) return true;
if (!x || !y || typeof x !== "object" || typeof y !== "object") return false;
const kx = Object.keys(x), ky = Object.keys(y);
if (kx.length !== ky.length) return false;
return kx.every(k => Object.hasOwn(y, k) && Object.is(x[k], y[k]));
}
For a deep compare, people often reach for JSON.stringify(a) === JSON.stringify(b), but I treat that as a hack: it depends on key order, it drops undefined and functions, and it turns Dates into strings. In a real codebase I'd use a tested helper like Lodash's isEqual, or better, avoid needing deep equality by keeping data immutable so a reference check is enough.
Interviewers look for: the connection to React. Mutating state keeps the same reference, so a shallow check sees "no change" and skips the re-render.
BasicWhat's the difference between Object.freeze, Object.seal and Object.preventExtensions?
So they're three levels of locking down an object, from loosest to strictest. preventExtensions stops you adding new keys, seal also stops you deleting keys, and freeze also stops you changing values.
"use strict";
const p = Object.preventExtensions({ x: 1 });
p.x = 2; // ok
delete p.x; // ok
p.y = 1; // TypeError
const s = Object.seal({ x: 1 });
s.x = 2; // ok
delete s.x; // TypeError
s.y = 1; // TypeError
const f = Object.freeze({ x: 1, nested: { y: 1 } });
f.x = 2; // TypeError
f.nested.y = 2; // works! freeze is shallow
One thing I always call out: in sloppy mode these failures are silent, the assignment just doesn't happen. In strict mode, which includes every ES module and class body, they throw. The other gotcha is on the last line: all three are shallow, so a frozen config object can still have its nested objects changed.
In practice I use freeze for things like constant lookup tables or enums, so nobody accidentally changes them at runtime.
BasicWhat's the difference between a shallow and a deep copy, and how do you make each one?
So a shallow copy creates a new top-level object, but any nested objects are still shared by reference. A deep copy recursively copies everything, so nothing is shared.
const user = { name: "Ana", address: { city: "Pune" }, joined: new Date() };
const shallow = { ...user }; // or Object.assign({}, user)
shallow.address.city = "Delhi";
user.address.city; // "Delhi" — shared!
const deep = structuredClone(user);
deep.address.city = "Goa";
user.address.city; // still "Delhi"
deep.joined instanceof Date; // true — Dates survive
For deep copies, structuredClone is what I use now. It's built into browsers and Node. It handles Dates, Maps, Sets, typed arrays and even circular references. The old JSON.parse(JSON.stringify(x)) trick turns Dates into strings, drops undefined and functions, turns Maps into {}, and throws on cycles.
The limits of structuredClone: it throws on functions and DOM nodes, and class instances come back as plain objects, so the prototype and methods are lost. So in practice, shallow copies for most state updates, structuredClone when I really need an independent copy of data.
Interviewers look for: knowing what structuredClone can't do (functions, prototypes), and the follow-up "write a deep clone yourself" that handles cycles with a WeakMap.
IntermediateWhat are property descriptors, and have you ever needed Object.defineProperty?
So every property has hidden flags besides its value: writable (can I change it?), enumerable (does it show up in Object.keys, for...in and JSON.stringify?), and configurable (can I delete it or change these flags?). Accessor properties have get/set instead of value/writable.
const user = { name: "Ana" };
Object.defineProperty(user, "id", { value: 42 }); // flags default to false!
Object.keys(user); // ["name"] — id is hidden
JSON.stringify(user); // '{"name":"Ana"}'
user.id = 7; // silently ignored (TypeError in strict mode)
user.id; // 42
Object.getOwnPropertyDescriptor(user, "name");
// { value: "Ana", writable: true, enumerable: true, configurable: true }
The gotcha here is that properties you create normally get all flags true, but with defineProperty anything you leave out defaults to false. People are often surprised that the property is read-only and invisible.
Where I've actually used it: adding a non-enumerable internal field, like a cache or an ID, to objects so it doesn't leak into serialized API payloads. Libraries use it too. Vue 2's reactivity was built on defineProperty getters and setters, which is why it couldn't detect newly added keys, and Vue 3 moved to Proxy.
IntermediateHow do getters and setters work, and when would you use them?
So a getter is a property that runs a function when you read it, and a setter runs a function when you assign to it. From the outside it looks like a normal property, which is the whole point.
class Temperature {
#celsius = 0;
get fahrenheit() { return this.#celsius * 9 / 5 + 32; }
set fahrenheit(f) {
if (typeof f !== "number") throw new TypeError("number expected");
this.#celsius = (f - 32) * 5 / 9;
}
}
const t = new Temperature();
t.fahrenheit = 212;
t.fahrenheit; // 212 — computed from #celsius, which is 100
I reach for them in two situations: derived values that should always be in sync, like fullName from first and last name, and validation on assignment, like the type check above.
One gotcha I've run into is infinite recursion. If you write get name() { return this.name; }, the getter calls itself until you get a "Maximum call stack size exceeded" RangeError. That's why you store the real value in a different field, ideally a #private one. I'd also keep getters cheap and free of side effects, because people read them like plain fields and won't expect them to do work.
IntermediateWhat does this log? Object.keys({ b: 1, 2: 1, a: 1, 1: 1 })
So it logs ["1", "2", "b", "a"]. Object key order isn't random, but it isn't pure insertion order either.
The rule is: integer-like keys come first in ascending numeric order, then string keys in the order they were inserted, then symbol keys in insertion order (though Object.keys skips symbols entirely).
const o = { b: 1, 2: 1, a: 1, 1: 1 };
Object.keys(o); // ["1", "2", "b", "a"]
o[1] === o["1"]; // true — keys are strings underneath
const ids = { "10": "x", "9": "y", "01": "z" };
Object.keys(ids); // ["9", "10", "01"] — "01" isn't a canonical integer
This came up for real in a project where we used an object keyed by numeric IDs to hold API results that were already sorted by date. As soon as we put them in the object, they got reordered by ID. The fix was to use a Map, which keeps pure insertion order, or to keep a separate array of IDs for ordering.
Interviewers look for: "integer keys first, ascending", and knowing that Map is the fix when order matters.
AdvancedThis config is frozen, but a teammate still mutated it. Why, and how would you write a deepFreeze?
So the bug is that Object.freeze is shallow. It locks the top-level properties, but a nested object is just a reference stored in a frozen slot, and the object at the end of that reference is still mutable.
const config = Object.freeze({ api: { baseUrl: "/v1" }, retries: 3 });
config.retries = 5; // blocked
config.api.baseUrl = "/v2"; // works — api itself was never frozen
function deepFreeze(obj) {
Object.freeze(obj); // freeze first, so cycles stop
for (const value of Object.values(obj)) {
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
deepFreeze(value);
}
}
return obj;
}
The one detail I'd point out is the order. I freeze before recursing, and skip anything already frozen. That way, if the object has a circular reference, the second visit sees it's frozen and stops. If you freeze at the end, a cycle recurses forever.
Trade-offs: this walks the whole tree, so I'd only do it once at startup for constants, or in development builds to catch accidental mutation. For large app state, I'd rather rely on immutable update patterns or a library like Immer, which freezes in dev mode for exactly this reason.
Interviewers look for: the cycle handling, and whether you'd also freeze Maps and Sets. freeze doesn't stop map.set(), because that data is internal, not properties.
12. Prototypes & the Prototype Chain
BasicWhat is the prototype chain?
So every object has a hidden link, [[Prototype]], to another object. When I read a property that isn't on the object itself, the engine follows that link and checks the next object, and so on, until it finds the property or hits null. That path is the prototype chain, and it's the only inheritance mechanism JavaScript really has.
const animal = { speak() { return `${this.name} makes a sound`; } };
const dog = Object.create(animal); // dog's prototype is animal
dog.name = "Rex";
dog.speak(); // "Rex makes a sound"
Object.getPrototypeOf(dog) === animal; // true
Object.getPrototypeOf(animal) === Object.prototype; // true
Object.getPrototypeOf(Object.prototype); // null — end of chain
Here, speak isn't on dog, so the lookup moves up to animal and finds it there. The important part is that this is still dog, because this is set by how you call the method, not by where it was found. That's why a shared method can work on every instance's own data.
It's also why [].map or "hi".toUpperCase exist. They live on Array.prototype and String.prototype, not on each value.
BasicWhat's the difference between __proto__ and prototype?
So they're easy to confuse, but they're different things. __proto__ exists on every object and points to that object's prototype, the next link in its chain. prototype is a regular property that exists only on functions, and it's the object that will become the prototype of instances created with new.
function User(name) { this.name = name; }
const u = new User("Ana");
u.__proto__ === User.prototype; // true — the instance links to it
User.prototype.constructor === User; // true
User.__proto__ === Function.prototype; // true — User itself is a function object
u.prototype; // undefined — instances don't have one
The way I remember it: prototype is the blueprint a constructor hands out, and __proto__ is the link each object actually holds.
In real code I don't use __proto__. It's a legacy accessor kept for compatibility. I use Object.getPrototypeOf and Object.setPrototypeOf, or better, Object.create when making the object, because changing a prototype after creation de-optimizes the object in V8.
Interviewers look for: "only functions have .prototype", and the follow-up: arrow functions and methods don't have one, which is why they can't be used with new.
IntermediatePredict the output. What's going on here?
const parent = { greet: "hi", count: 0 };
const child = Object.create(parent);
child.greet = "hey";
child.count++;
console.log(parent.greet, parent.count, child.count);
So it prints hi 0 1. This is shadowing. Reads go up the chain, but writes create an own property on the object you assigned to. The prototype is never touched.
Walking through it: child.greet = "hey" creates an own greet on child that hides the parent's. child.count++ is really child.count = child.count + 1. The read finds 0 on the parent, and the write puts 1 on child. So the parent still has hi and 0.
Object.hasOwn(child, "count"); // true — now shadowed
delete child.count;
child.count; // 0 — the inherited one is visible again
There are two exceptions to "writes go on the object", and I'd mention them if pushed. If the prototype has a setter for that key, the setter runs instead of creating an own property. If the inherited property is non-writable, the assignment silently fails, or throws in strict mode.
IntermediateWalk me through what new does, step by step. Can you implement it?
So new Foo(args) does four things. It creates an empty object, links that object's prototype to Foo.prototype, runs Foo with this bound to the new object, and returns the new object, unless the constructor explicitly returns a different object.
The easiest way to show it is to write it:
function myNew(Ctor, ...args) {
const obj = Object.create(Ctor.prototype); // steps 1 + 2
const result = Ctor.apply(obj, args); // step 3
const isObject = result !== null &&
(typeof result === "object" || typeof result === "function");
return isObject ? result : obj; // step 4
}
function User(name) { this.name = name; }
User.prototype.hi = function () { return "hi " + this.name; };
myNew(User, "Ana").hi(); // "hi Ana"
The subtle part is step 4. If a constructor does return { x: 1 }, that object replaces the instance. But return 5 or return "x" is ignored, and you still get the instance. I've seen that trip people up in questions like "what does new return here?"
Also, new on an arrow function throws a TypeError, because arrows have no prototype and no own this.
Interviewers look for: the primitive-vs-object return rule in step 4, and using Object.create rather than setting __proto__.
IntermediateWhat is Object.create for, and why would anyone use Object.create(null)?
So Object.create(proto) makes a new object whose prototype is exactly the object you pass. It's the most direct way to set up inheritance without a constructor or a class.
Object.create(null) gives you an object with no prototype at all, not even Object.prototype. That makes it a clean dictionary:
const counts = {};
"constructor" in counts; // true — inherited from Object.prototype!
counts["toString"]; // a function, not undefined
const dict = Object.create(null);
"constructor" in dict; // false — truly empty
dict.hasOwnProperty("x"); // TypeError: not a function
Object.hasOwn(dict, "x"); // false — works on any object
I ran into this when counting words from user input. The word "constructor" came back as a function instead of undefined, and counts[word]++ produced garbage. A null-prototype object, or a Map, fixes it.
That also explains why Object.hasOwn exists. obj.hasOwnProperty fails on null-prototype objects, and it can be shadowed by a key called hasOwnProperty. Object.hasOwn(obj, key) is a static function, so neither problem applies. It's what I use by default now.
IntermediateThis code has a bug: adding a member to one team adds it to every team. Why?
function Team(name) { this.name = name; }
Team.prototype.members = [];
const a = new Team("A");
const b = new Team("B");
a.members.push("Ana");
b.members; // ["Ana"] ?!
So the array lives on the prototype, and there's only one prototype object shared by every instance. a.members.push(...) is a read of members, which walks up the chain to the one shared array, followed by a mutation of that array. No assignment happens, so no own property is created on a.
Compare that with a.name = "X", which is an assignment, so it creates an own property. Mutating through a reference and assigning behave completely differently with prototypes.
function Team(name) {
this.name = name;
this.members = []; // per-instance state goes here
}
Team.prototype.add = function (m) { this.members.push(m); }; // shared behaviour
So the rule I follow is: state goes on the instance, behaviour goes on the prototype. Methods are shared on purpose, because one copy for thousands of instances saves memory. Mutable data should never be shared by accident. Class fields make this natural: members = []; in a class body runs once per instance.
Interviewers look for: the difference between mutating an inherited object and assigning a property, which is the same idea as shadowing.
AdvancedHow does instanceof work, and when can it lie to you?
So x instanceof C walks x's prototype chain and returns true if any link is C.prototype. It doesn't check who constructed the object. It only checks the chain.
function myInstanceOf(obj, Ctor) {
if (obj === null || (typeof obj !== "object" && typeof obj !== "function")) return false;
let proto = Object.getPrototypeOf(obj);
while (proto) {
if (proto === Ctor.prototype) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}
Because it only checks the chain, it can give surprising answers in three cases I know of:
// 1. The prototype gets swapped after creation
function A() {}
const a = new A();
A.prototype = {};
a instanceof A; // false — a still links to the OLD prototype
// 2. Cross-realm: an array from an iframe has a different Array.prototype
iframeArr instanceof Array; // false
Array.isArray(iframeArr); // true — use this instead
// 3. A class can override it with Symbol.hasInstance
class Even { static [Symbol.hasInstance](n) { return n % 2 === 0; } }
2 instanceof Even; // true
So in practice I use instanceof for my own classes, especially error types in a catch. For built-in types I prefer Array.isArray, and for other objects I check for the shape or behaviour I need.
13. Classes
BasicAre JavaScript classes just syntactic sugar over prototypes?
So mostly yes. Under the hood a class is still a constructor function, and its methods still live on .prototype. But I'd say "sugar with some real differences", because a few behaviours can't be reproduced exactly with a plain function.
class Dog { bark() { return "woof"; } }
typeof Dog; // "function"
Object.hasOwn(Dog.prototype, "bark"); // true
Dog(); // TypeError — class constructors require new
new Cat(); // ReferenceError — TDZ, classes aren't usable before declaration
class Cat {}
The differences I'd list are:
First, you can't call a class without new. Second, class declarations are hoisted but sit in the temporal dead zone, like let. Third, the class body is always strict mode. Fourth, methods are non-enumerable, so they don't show up in for...in. And finally, #private fields are genuinely new. There's no pre-class way to get engine-enforced privacy on an object's own slot.
So when someone says "it's just sugar", I agree with the mental model. The prototype chain is exactly the same, but I'd point out that the guard rails are real.
BasicWhat does super do, and why must super() come before this in a subclass constructor?
So super has two uses: super(...) in a constructor calls the parent constructor, and super.method() anywhere in the class calls the parent's version of a method.
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
}
class Dog extends Animal {
constructor(name, breed) {
// this.breed = breed; // ReferenceError if placed here
super(name);
this.breed = breed;
}
speak() { return `${super.speak()}, specifically a woof`; }
}
new Dog("Rex", "lab").speak(); // "Rex makes a sound, specifically a woof"
The reason for the ordering rule is that in a derived class, the subclass doesn't create the object. The parent constructor does. That matters for built-ins: when you extend Array or Error, the object has to come from their constructor to get their internal behaviour. So until super() returns, there's no this yet, and touching it throws a ReferenceError.
If a subclass doesn't define a constructor at all, JavaScript gives it one that just calls super(...args), so I only write one when I actually need to add something.
IntermediateWhat are static members, and when do you use them?
So a static member lives on the class itself, not on instances. You call it as User.fromJSON(), not user.fromJSON(). Inside a static method, this is the class.
class User {
static #count = 0;
static { // static initialization block (ES2022)
// one-time setup, e.g. read config
}
constructor(name) { this.name = name; User.#count++; }
static fromJSON(json) { // factory
const { name } = JSON.parse(json);
return new this(name); // `this` = whichever class it was called on
}
static get count() { return User.#count; }
}
class Admin extends User {}
Admin.fromJSON('{"name":"Ana"}') instanceof Admin; // true
I use static members for three things. Factory methods like fromJSON or Date.now-style helpers. Class-level state like counters or caches. And constants tied to the class, like HttpError.NOT_FOUND.
A nice detail in the example: statics are inherited. Admin.fromJSON works because Admin's own prototype is User, and using new this(...) instead of new User(...) means the factory builds the right subclass.
Interviewers look for: knowing that statics are inherited, since the class-side prototype chain is Admin → User, and the new this() trick.
IntermediateHow do #private fields differ from the _underscore convention or closures?
So _balance is just a naming convention. Anyone can read or write it. #balance is enforced by the engine: code outside the class body can't even reference it, and doing so is a syntax error, not just a runtime failure.
class Account {
#balance = 0;
deposit(n) { this.#balance += n; }
get balance() { return this.#balance; }
static isAccount(obj) { return #balance in obj; } // brand check
}
const acc = new Account();
acc.deposit(50);
acc.balance; // 50
acc.#balance; // SyntaxError — outside the class
Object.keys(acc); // [] — private fields are invisible
Account.isAccount({}); // false
Compared with closures, which was the old way to get real privacy: closures work, but every instance has to create its own copies of the methods, since they must be defined inside the constructor. With #fields the methods stay on the prototype and are shared.
The gotchas I know about: private fields are not inherited in an accessible way, so a subclass can't read the parent's #balance directly. And a Proxy around an instance breaks them, because the method runs with this set to the proxy, which doesn't have the private slot, so you get a TypeError. That's worth knowing if you use a state library that wraps objects in proxies.
IntermediateWhy does this become undefined when you pass a class method as a callback, and how do class fields fix it?
So a normal method lives on the prototype and gets this from how it's called. When I pass this.handleClick to an event listener or setTimeout, I'm passing a bare function. It gets called without an object in front of it, and since class bodies are strict mode, this is undefined.
class Counter {
count = 0;
increment() { this.count++; } // prototype method
incrementArrow = () => { this.count++; }; // class field arrow
}
const c = new Counter();
setTimeout(c.increment, 0); // TypeError: Cannot read properties of undefined
setTimeout(c.incrementArrow, 0); // works
setTimeout(() => c.increment(), 0); // also works
setTimeout(c.increment.bind(c), 0); // also works
The arrow-function field works because class fields are initialized per instance, inside the construction step. An arrow captures this from where it was created, which is the new instance, so it's permanently bound.
The trade-off is that the arrow field is a new function per instance, not a shared prototype method. So it uses more memory, it can't be overridden with super.method() in a subclass, and it's harder to mock on the prototype in tests. For a handful of UI handlers, I use arrow fields. For a class with thousands of instances, I'd bind at the call site instead.
AdvancedHow do you extend built-ins like Error or Array, and what are the gotchas?
So with native classes, extending built-ins just works now. The common real case is custom errors, so let me show that first.
class HttpError extends Error {
constructor(status, message, options) {
super(message, options); // options can carry { cause }
this.name = "HttpError"; // otherwise stack traces say "Error"
this.status = status;
}
}
try {
throw new HttpError(404, "User not found", { cause: originalErr });
} catch (e) {
if (e instanceof HttpError && e.status === 404) showEmptyState();
else throw e;
}
The thing I always set is this.name, so logs and stack traces show the right type. cause lets me wrap a low-level error without losing it.
Two gotchas. First, historically instanceof HttpError returned false when TypeScript or Babel compiled classes down to ES5, because ES5 functions can't properly subclass Error. With a modern target that's gone, but it still bites people on old build configs.
class Stack extends Array {
peek() { return this.at(-1); }
}
const s = Stack.from([1, 2, 3]);
s.peek(); // 3
s.map(x => x * 2) instanceof Stack; // true — via Symbol.species
Second, with Array subclasses, methods like map and filter return your subclass, not a plain array. That's usually nice but can surprise you if the subclass constructor expects different arguments. For most cases I'd rather wrap an array than extend it.
Interviewers look for: setting name, using cause, and the "transpiled to ES5" instanceof issue if you've worked on older codebases.
AdvancedPredict the output. Why does the subclass field show undefined?
class Base {
constructor() { this.init(); }
init() {}
}
class Child extends Base {
name = "child";
init() { console.log(this.name); }
}
new Child();
So it logs undefined. This comes down to the order in which class fields get initialized.
For a derived class, the fields are set right after super() returns. So the sequence is: Child's implicit constructor calls super(). Base's constructor runs and calls this.init(), which dispatches to Child.prototype.init because this is a Child. At that moment name hasn't been assigned yet, so it logs undefined. Only after Base finishes does name = "child" run.
// Fix: don't call overridable methods from a constructor.
class Base {
constructor() { /* only set own state */ }
static create(...args) { const o = new this(...args); o.init(); return o; }
}
I've seen this in real code where a base component class called a render() hook from its constructor. The general lesson, which applies in Java and C# too, is that calling virtual methods from a constructor exposes a half-built object. So I keep constructors to assigning state, and do the lifecycle work in a separate step or a static factory.
AdvancedWhen would you choose composition over class inheritance?
So my default is composition, and I use inheritance only for a shallow, true "is-a" relationship. Deep class hierarchies get fragile. A change in a base class ripples into every subclass, and you end up forcing things into a tree that aren't really a tree.
The classic example: say I have Bird with fly(), then Penguin extends Bird, and now I'm overriding fly to throw. Or I need a FlyingFish that swims and flies, and it doesn't fit anywhere. With composition I just combine capabilities:
const canSwim = (self) => ({ swim: () => `${self.name} swims` });
const canFly = (self) => ({ fly: () => `${self.name} flies` });
function createFlyingFish(name) {
const self = { name };
return Object.assign(self, canSwim(self), canFly(self));
}
createFlyingFish("Nemo").fly(); // "Nemo flies"
In frontend work this is basically what React did. It moved from class components and mixins to hooks. useAuth, useFetch and useDebounce are composed capabilities, not a base class.
Where I'd still use extends: error types (HttpError extends Error), framework base classes you're required to extend like HTMLElement for web components, and small hierarchies one level deep where the subtypes truly share behaviour.
Interviewers look for: a concrete reason (fragile base class, the diamond or "doesn't fit the tree" problem), not just "composition is better".
14. Arrays & Iteration
BasicWhat's the difference between map, forEach, filter and reduce?
So they all loop over an array, but the difference is what they give back. map returns a new array of the same length with each item transformed. filter returns a new array with only the items that pass a test. reduce boils the array down to any single value. forEach returns undefined. It's only for side effects.
const cart = [
{ name: "pen", price: 10, inStock: true },
{ name: "book", price: 250, inStock: false },
{ name: "bag", price: 900, inStock: true },
];
cart.map(i => i.name); // ["pen", "book", "bag"]
cart.filter(i => i.inStock); // pen and bag objects
cart.reduce((sum, i) => sum + i.price, 0); // 1160
cart.forEach(i => console.log(i.name)); // logs, returns undefined
// chaining reads like a sentence
cart.filter(i => i.inStock).map(i => i.price).reduce((a, b) => a + b, 0); // 910
The way I choose: if I'm building a new array, map or filter. If I'm computing a total or building an object, reduce. If I'm doing something like logging or writing to the DOM, forEach or a for...of.
A common code-review comment I leave is "don't use map just to loop". If you ignore the returned array, it should be forEach, because map signals to the reader that you're transforming data.
BasicWhy does [10, 9, 1].sort() give [1, 10, 9]?
So by default sort converts every element to a string and compares them by UTF-16 code units, like a dictionary. As strings, "10" comes before "9" because "1" is less than "9".
[10, 9, 1].sort(); // [1, 10, 9]
[10, 9, 1].sort((a, b) => a - b); // [1, 9, 10] ascending
[10, 9, 1].sort((a, b) => b - a); // [10, 9, 1] descending
users.sort((a, b) => a.name.localeCompare(b.name)); // proper text sort
// bug I've seen in code review:
nums.sort((a, b) => a > b); // returns true/false, never negative — unreliable
The comparator should return a negative number, zero or a positive number. That last line is a real bug. A boolean becomes 1 or 0, never negative, so the engine can't tell "less than". In V8, even [3, 1, 2].sort((a, b) => a > b) comes back unsorted.
Two other things I'd mention. sort mutates the original array and returns the same reference, so in React state I use toSorted or copy first. And since ES2019 sort is guaranteed stable, so equal elements keep their original order. That means I can sort by date and then by status, and items with the same status stay in date order.
BasicHow do you remove duplicates from an array, including an array of objects?
So for primitives, the one-liner is [...new Set(arr)]. A Set only keeps unique values and preserves insertion order, so you keep the first occurrence of each.
const tags = ["js", "css", "js", "html", "css"];
[...new Set(tags)]; // ["js", "css", "html"]
// objects are compared by reference, so Set alone won't help:
const users = [{ id: 1, n: "Ana" }, { id: 2, n: "Raj" }, { id: 1, n: "Ana" }];
const seen = new Set();
const unique = users.filter(u => !seen.has(u.id) && seen.add(u.id));
// [{ id: 1 ... }, { id: 2 ... }] — keeps the first occurrence
For objects I dedupe by a key, like id. The filter trick works because seen.add returns the set, which is truthy. It's O(n), compared with the findIndex-inside-filter version you often see, which is O(n²).
One subtle point: the old arr.filter((x, i) => arr.indexOf(x) === i) approach removes NaN completely, because indexOf(NaN) is always -1. Set uses SameValueZero, which treats NaN as equal to itself, so it keeps one.
Interviewers look for: the Set one-liner, then the O(n) approach for objects. Mentioning complexity is what separates candidates here.
IntermediateCan you break out of a forEach? And what happens with async inside it?
So no, break and continue are syntax errors inside a forEach callback, and return only exits that one callback call, like a continue. The only way to stop it early is to throw, which is a hack.
// stop at the first match — pick the right tool instead
for (const item of items) { if (item.bad) break; }
items.some(item => item.bad); // stops at first true
items.find(item => item.id === 7); // stops at first match
The async problem is the bigger one in real code. forEach calls your callback and ignores its return value. With an async callback, that return value is a promise, so nothing waits for it.
items.forEach(async (item) => { await save(item); });
console.log("done"); // logs BEFORE any save finishes
for (const item of items) await save(item); // one after another
await Promise.all(items.map(item => save(item))); // all at once
I actually hit this in a project. A "Saved!" toast showed while requests were still in flight, and when one failed, the error was an unhandled rejection because nothing was awaiting it. The fix was Promise.all with map. I choose between the two versions based on whether order matters and whether the server can handle parallel requests. For large batches I'd limit concurrency.
IntermediateWhat's the difference between for...in and for...of?
So for...in loops over keys, meaning enumerable string property names, including inherited ones. for...of loops over values from anything iterable: arrays, strings, Maps, Sets, generators.
const arr = ["a", "b"];
arr.extra = "x";
for (const k in arr) console.log(k); // "0", "1", "extra" — string keys!
for (const v of arr) console.log(v); // "a", "b"
for (const [key, val] of Object.entries({ x: 1 })) {} // objects: go via entries
for (const ch of "hi") {} // "h", "i"
for (const [k, v] of new Map([["a", 1]])) {}
Here, for...in on the array gives index strings, and it also picks up the extra property. If some library had added to Array.prototype, it would pick that up too. So I never use for...in on arrays.
And for...of on a plain object throws TypeError: obj is not iterable, because plain objects don't implement Symbol.iterator. For objects I use Object.entries, which gives only own enumerable keys.
In practice, for...of is my default loop, because it supports break, continue and await, and it reads clearly.
IntermediateWhich array methods mutate, and what are toSorted, toReversed, toSpliced and with?
So the mutating ones are push, pop, shift, unshift, splice, sort, reverse, fill and copyWithin. Everything else, like map, filter, slice and concat, returns a new array.
ES2023 added non-mutating versions of the ones people most often misuse:
const nums = [3, 1, 2];
nums.toSorted(); // [1, 2, 3] nums unchanged
nums.toReversed(); // [2, 1, 3]
nums.toSpliced(0, 1); // [1, 2] remove index 0
nums.with(1, 99); // [3, 99, 2] replace index 1
nums; // [3, 1, 2]
// React: the bug and the fix
setTodos(todos.sort(byDate)); // mutates state, same reference, may not re-render
setTodos(todos.toSorted(byDate)); // new array
The React case is where this really matters. sort mutates in place and returns the same array, so React sees the same reference and may skip the update. And you've quietly changed the previous state that other code might still hold. Before these methods, I'd write [...todos].sort(byDate). Now toSorted says what I mean.
Interviewers look for: knowing that sort and reverse mutate. Many candidates assume they return copies.
IntermediatePredict the output: ["1", "2", "3"].map(parseInt) and Array(3).map((_, i) => i).
So the first gives [1, NaN, NaN] and the second gives [empty × 3], which is still three holes. Both are classic traps.
For the first one, map passes three arguments: element, index and array. parseInt takes a second argument, the radix. So the calls are:
parseInt("1", 0); // 1 — radix 0 means "auto", so base 10
parseInt("2", 1); // NaN — radix 1 is invalid
parseInt("3", 2); // NaN — "3" isn't a binary digit
["1", "2", "3"].map(Number); // [1, 2, 3]
["1", "2", "3"].map(s => parseInt(s, 10));
For the second, Array(3) creates a sparse array with length 3 but no actual elements, only holes. map, forEach and filter skip holes, so the callback never runs.
Array(3).map((_, i) => i); // [empty × 3]
Array.from({ length: 3 }, (_, i) => i); // [0, 1, 2]
[...Array(3)].map((_, i) => i); // [0, 1, 2] — spread turns holes into undefined
Array(3).fill(0); // [0, 0, 0]
The lesson I take from the first one is to be careful passing a function reference straight into map, unless I know its signature. From the second: build arrays with Array.from.
AdvancedGroup a list of orders by status. What's the reduce pitfall, and what's the modern built-in?
So the classic answer is reduce with an object accumulator, and the modern one is Object.groupBy from ES2024. Let me show both.
const orders = [
{ id: 1, status: "paid" },
{ id: 2, status: "pending" },
{ id: 3, status: "paid" },
];
const byStatus = orders.reduce((acc, o) => {
(acc[o.status] ??= []).push(o);
return acc; // forgetting this return is the #1 bug
}, {});
Object.groupBy(orders, o => o.status);
// { paid: [order 1, order 3], pending: [order 2] } (null-prototype object)
Map.groupBy(orders, o => o.status); // same, as a Map
The pitfalls with reduce that I watch for: forgetting to return acc, so the next iteration gets undefined. And leaving out the initial value. Without it, reduce uses the first element as the accumulator, which is wrong for grouping, and on an empty array it throws TypeError: Reduce of empty array with no initial value. That's a real production crash when an API returns [].
A performance trap: reduce((acc, x) => ({ ...acc, [x.k]: x }), {}) copies the accumulator on every step, so it's O(n²). For a few thousand items, that's noticeable. I mutate the accumulator inside reduce, because it's a local object nobody else can see.
Interviewers look for: always passing an initial value, and seeing the O(n²) spread-in-reduce pattern.
15. Map, Set, Symbol & friends
BasicWhen would you use a Map instead of a plain object?
So I use a Map when I have a real key-value collection that changes at runtime, especially with non-string keys, and a plain object when I have a fixed shape, like a record or a config.
const cache = new Map();
cache.set(userObj, profile); // object as key — impossible with {}
cache.set(42, "answer"); // number stays a number
cache.size; // 2 — no Object.keys(...).length
for (const [key, value] of cache) {} // directly iterable, insertion order
const obj = {};
obj["__proto__"] = "x"; // doesn't create a key — hits the prototype setter
obj.toString; // inherited — surprise key
The differences I'd point to: Map keys can be any type, while object keys become strings. Map keeps pure insertion order, while objects put integer-like keys first. Map has size and is directly iterable. It has no inherited keys like toString or __proto__. And V8 optimizes it for frequent adds and deletes, while delete on objects can make them slower.
The downsides of Map: JSON.stringify(map) gives "{}", so you have to convert with Object.fromEntries(map) first, and it's a bit more verbose. So for API payloads and component props I use objects. For caches, lookups by ID, or anything keyed by DOM nodes or objects, I use a Map.
BasicWhat is a Set good for beyond removing duplicates?
So a Set is a collection of unique values with fast membership checks. has is O(1) on average, compared with includes on an array, which is O(n). That's the real win beyond deduping.
const selected = new Set();
selected.add(3).add(7);
selected.has(3); // true — fast lookup
selected.delete(3);
// "is this in the allowed list?" inside a hot loop
const allowed = new Set(["admin", "editor"]);
users.filter(u => allowed.has(u.role));
// ES2025 set operations, now in all major browsers
const a = new Set([1, 2, 3]), b = new Set([2, 3, 4]);
a.intersection(b); // Set {2, 3}
a.union(b); // Set {1, 2, 3, 4}
a.difference(b); // Set {1}
a.isSubsetOf(b); // false
A real example: in a table with row selection, I stored selected IDs in a Set. Checking "is this row selected?" for every rendered row became constant time, and toggling is just add or delete. In React, I create a new Set on each update (new Set(prev).add(id)), because mutating the existing one keeps the same reference.
Also, Set uses SameValueZero equality, so NaN matches NaN, but two objects with the same content are still two separate entries.
IntermediatePredict the output. Why does this lookup return the wrong value?
const a = { id: 1 };
const b = { id: 2 };
const lookup = {};
lookup[a] = "first";
lookup[b] = "second";
console.log(lookup[a], Object.keys(lookup));
So it prints second ["[object Object]"]. Object keys can only be strings or symbols, so when I use an object as a key, it's converted with String(a), which is "[object Object]". Both a and b become the same key, and the second assignment overwrites the first.
const byRef = new Map();
byRef.set(a, "first");
byRef.set(b, "second");
byRef.get(a); // "first" — keyed by reference
byRef.get({ id: 1 }); // undefined — a different object
With a Map, keys are compared by identity, so each object gets its own entry. The last line also shows the flip side: you need the same reference to look something up, not an equal-looking object.
I've seen this bug when someone memoized results keyed by an options object. Every call "hit" the cache and returned the wrong data. The fix was a Map or WeakMap keyed by reference, or a stable string key like JSON.stringify of the options, if equal content should share a cache entry.
IntermediateWhat are WeakMap and WeakSet, and what would you actually use them for?
So they're versions of Map and Set where the keys must be objects (or non-registered symbols), and the reference to the key is weak. If nothing else in the program references the key object, it can be garbage collected, and the entry disappears with it. That's why they aren't iterable and have no size: the contents can change whenever the GC runs.
// Attach data to objects you don't own, without leaking memory
const clickCount = new WeakMap();
function track(el) {
clickCount.set(el, (clickCount.get(el) ?? 0) + 1);
}
// When `el` is removed from the DOM and dropped, its entry goes too.
// Cache derived data per object
const cache = new WeakMap();
function expensive(obj) {
if (!cache.has(obj)) cache.set(obj, compute(obj));
return cache.get(obj);
}
// WeakSet: mark objects as "seen", e.g. to handle cycles in a deep clone
const visited = new WeakSet();
The use cases I've actually had: metadata on DOM nodes or third-party objects, per-object memoization, and tracking visited objects when walking a graph. Before private fields, WeakMaps were also the way to store private data per instance.
If I used a normal Map for the DOM case, the Map would keep every removed element alive forever. That's a classic memory leak in single-page apps.
Interviewers look for: why they can't be iterated, since GC timing isn't observable, and a concrete leak that a normal Map would cause.
IntermediateWhat is a Symbol, and why would you use one?
So a Symbol is a primitive value that's guaranteed unique. Even two symbols with the same description aren't equal. Their main use is as property keys that can't clash with anyone else's keys.
Symbol("id") === Symbol("id"); // false
Symbol.for("app.id") === Symbol.for("app.id"); // true — global registry
const META = Symbol("meta");
const user = { name: "Ana", [META]: { fetchedAt: Date.now() } };
Object.keys(user); // ["name"] — symbol keys are skipped
JSON.stringify(user); // '{"name":"Ana"}'
for (const k in user) {} // skips it too
user[META]; // still accessible if you have the symbol
Object.getOwnPropertySymbols(user); // [Symbol(meta)]
Where I'd use it: when I need to attach something to an object that I don't control, like a library's object or a DOM node, without risking a clash with an existing or future string key. And when I don't want it to show up in serialization or normal loops.
I'd be clear that symbols are not private. Reflect.ownKeys and getOwnPropertySymbols reveal them. They're for avoiding collisions, not for hiding. For real privacy I'd use #fields.
The bigger reason symbols matter is the well-known symbols, like Symbol.iterator. They're how the language lets your own objects hook into built-in behaviour.
AdvancedWhat are well-known symbols? Show me one you'd actually implement.
So well-known symbols are built-in symbols like Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive, Symbol.hasInstance and Symbol.toStringTag. The language looks for these keys on your objects, so implementing one plugs your object into syntax like for...of, spread or +.
The one I've actually used is Symbol.iterator:
class Range {
constructor(from, to) { this.from = from; this.to = to; }
*[Symbol.iterator]() {
for (let i = this.from; i <= this.to; i++) yield i;
}
}
const r = new Range(1, 3);
[...r]; // [1, 2, 3]
for (const n of r) {} // works
const [first] = r; // destructuring works too
Using a generator method means I don't have to hand-write the { next() { return { value, done } } } object.
const price = {
amount: 42,
[Symbol.toPrimitive](hint) {
return hint === "number" ? this.amount : `$${this.amount}`;
},
};
+price; // 42 (hint "number")
`${price}`; // "$42" (hint "string")
price + 1; // "$421" (hint "default", returns a string, so concatenation)
Symbol.toPrimitive controls coercion, and the last line shows why I'd use it sparingly. + passes the "default" hint, so you get string concatenation. For a paginated API, I've used Symbol.asyncIterator so callers can write for await (const page of client.pages()).
Interviewers look for: making a custom object iterable, ideally with a generator. It connects nicely to the iterators and generators topic.
AdvancedWhat are WeakRef and FinalizationRegistry, and should you use them?
So a WeakRef holds a reference to an object without keeping it alive. ref.deref() gives you the object, or undefined if it's been garbage collected. FinalizationRegistry lets you register a callback that may run some time after an object is collected.
const cache = new Map(); // key -> WeakRef(bigObject)
const registry = new FinalizationRegistry(key => {
if (!cache.get(key)?.deref()) cache.delete(key); // tidy up stale entries
});
function getImage(url) {
const cached = cache.get(url)?.deref();
if (cached) return cached;
const img = decodeLargeImage(url);
cache.set(url, new WeakRef(img));
registry.register(img, url);
return img;
}
This is the one pattern where they fit: a cache of large objects keyed by a string. WeakMap can't do that, since it needs object keys. If memory gets tight, the GC can drop the images and I just recompute them.
My honest answer to "should you use them" is: rarely. The spec gives almost no guarantees. GC timing differs between engines and between runs, finalizers might never run, for example if the tab closes, and deref() can return the object in one tick and undefined in a later one. So I'd never put correctness-critical logic, like closing a connection or saving data, in a finalizer. That should be explicit cleanup, like a dispose() call or using declarations where they're available.
16. Destructuring, Spread & Rest
BasicHow do defaults and renaming work in destructuring? When does a default not apply?
So destructuring pulls values out of objects or arrays into variables. With objects I can rename with a colon and set a fallback with =. The important catch: a default only applies when the value is undefined, not null and not any other falsy value.
const res = { data: null, status: 0, user: { name: "Ana" } };
const { user: { name: userName }, status = 200, data = [] } = res;
userName; // "Ana" — renamed from name
status; // 0 — default ignored, 0 isn't undefined
data; // null — default ignored, null isn't undefined!
const [first = "none", , third] = ["a", "b", "c"]; // skip with a hole
first; // "a"
third; // "c"
The data = [] line is a real bug I've seen. The API returned null for "no results", the default never kicked in, and then data.map crashed. If null is possible, I use const list = data ?? [] after destructuring.
The syntax I remember as "old name on the left, new name on the right": { name: userName } reads the name property into a variable called userName. It can be combined with a default: { name: userName = "Guest" }.
Interviewers look for: "defaults only trigger on undefined". It's the most common follow-up.
BasicHow do you swap two variables without a temporary variable?
So with array destructuring: [a, b] = [b, a]. The right side builds a temporary array from the current values, then the left side unpacks it back in the opposite order.
let a = 1;
let b = 2;
[a, b] = [b, a];
a; // 2
b; // 1
// swapping array elements — handy in sorting algorithms
const arr = [10, 20, 30];
[arr[0], arr[2]] = [arr[2], arr[0]]; // [30, 20, 10]
One gotcha I've run into: if you don't use semicolons, and the previous line doesn't end with one, a line starting with [ gets merged into the previous line. b = 2 followed by [a, b] = [b, a] on the next line is parsed as b = 2[a, b] = ..., and you get a confusing error or wrong values. So in no-semicolon codebases you'll see ;[a, b] = [b, a].
Performance-wise, it allocates a small array, so in a very hot loop a temp variable is technically faster, but engines optimize this well. In practice I use the destructuring version for readability.
BasicSpread and rest both use .... What's the difference?
So it's the same three dots doing opposite jobs, and the position tells you which one it is. Spread expands a collection into individual items. It shows up where values are used: calls, array literals, object literals. Rest gathers the remaining items into one array or object. It shows up where values are received: function parameters and the left side of destructuring.
// spread — expanding
Math.max(...[3, 9, 2]); // 9
const all = [...frontend, ...backend];
const updated = { ...user, name: "Ana" };
// rest — collecting
function log(level, ...messages) { // messages is a real array
console[level](...messages); // and spread again here
}
const [head, ...tail] = [1, 2, 3]; // head 1, tail [2, 3]
const { password, ...safeUser } = user; // omit a field
That last line is one of my favourite patterns. It strips sensitive fields before sending a user object to the client, without mutating the original.
Two rules for rest: it must be the last element, so [...a, b] on the left side is a syntax error, and there can only be one. Rest parameters also replaced the old arguments object. arguments isn't a real array, has no map, and doesn't exist in arrow functions. Rest gives you a proper array everywhere.
IntermediateNested destructuring crashes when the API leaves out a field. How do you make it safe?
So nested destructuring assumes every level exists. If an intermediate object is undefined, you're trying to destructure undefined, which throws a TypeError. The fix is to give each nested level its own default.
const res = { user: { name: "Ana" } }; // no address field
const { user: { address: { city } } } = res;
// TypeError: Cannot read properties of undefined (reading 'city')
const { user: { address: { city = "Unknown" } = {} } = {} } = res;
city; // "Unknown"
Here the = {} after address says "if address is undefined, destructure an empty object instead". Then city falls back to its own default. I add = {} at every level that might be missing.
That said, I'm honest in code review about readability. That last line is hard to scan. When the data shape is unreliable, I often prefer optional chaining:
const city = res.user?.address?.city ?? "Unknown";
This also handles null, which destructuring defaults don't. So my rule is: destructure one or two levels when the shape is guaranteed, and use ?. with ?? for deep or optional paths from APIs. Ideally I'd validate the response once at the boundary, with something like Zod, so the rest of the code can trust it.
Interviewers look for: the = {} per level, and knowing that it won't protect against null.
IntermediateThis React state update uses spread, but the old state still changes. Why?
const next = { ...state };
next.settings.theme = "dark";
setState(next);
So spread is a shallow copy. next is a new object, but next.settings is the exact same object as state.settings, because only the reference was copied. Changing theme changes the settings object that the old state also points to.
That causes two kinds of bugs: the previous state is silently modified, which breaks undo, and anything that memoizes on state.settings, like React.memo or useMemo, sees the same reference and doesn't update.
// copy every level you change
setState(prev => ({
...prev,
settings: { ...prev.settings, theme: "dark" },
}));
// arrays inside: same idea
setState(prev => ({
...prev,
todos: prev.todos.map(t => t.id === id ? { ...t, done: true } : t),
}));
The rule I follow is to spread along the path you change, and leave untouched branches shared. That structural sharing is actually good. Unchanged parts keep their references, so memoized children don't re-render.
When nesting gets deep, I'd reach for Immer, which Redux Toolkit uses. It lets you write draft.settings.theme = "dark" and produces the correct immutable copy. Or I'd flatten the state shape so it doesn't need deep updates.
IntermediatePredict the output. How does order work when spreading objects?
const defaults = { theme: "light", size: 12, debug: false };
const options = { size: 14, theme: undefined };
console.log({ ...defaults, ...options });
console.log({ ...options, ...defaults });
So the first prints { theme: undefined, size: 14, debug: false } and the second prints { size: 12, theme: "light", debug: false }. With object spread, later wins. Each spread or property writes its keys in turn, overwriting any earlier ones.
The surprise is in the first line: theme becomes undefined. Spread copies own properties whether or not their value is undefined. Unlike destructuring defaults, it doesn't skip them. So { theme: undefined } wipes out the default. That's a real bug when a form sends every field, including the empty ones.
Also note the key order. A key keeps the position where it was first created, even if a later spread overwrites its value. That's why the second object lists size before theme.
// place overrides deliberately
const config = { ...defaults, ...options, debug: true }; // debug forced
// drop undefined values first if you want defaults to survive
const clean = Object.fromEntries(
Object.entries(options).filter(([, v]) => v !== undefined)
);
const merged = { ...defaults, ...clean }; // theme stays "light"
Interviewers look for: "later wins", plus the undefined overwrite. That's the part that separates people who've been bitten by it.
AdvancedWhy do you often see = {} after a destructured function parameter?
So parameter destructuring runs on whatever is passed in. If the caller passes nothing, you're destructuring undefined, which throws. The trailing = {} is a default for the whole parameter, and the inner defaults cover the individual fields.
function createUser({ name = "Guest", role = "viewer" }) {}
createUser(); // TypeError — cannot destructure undefined
function createUser({ name = "Guest", role = "viewer" } = {}) {
return { name, role };
}
createUser(); // { name: "Guest", role: "viewer" }
createUser({ role: "admin" }); // { name: "Guest", role: "admin" }
createUser(null); // still TypeError — null isn't undefined
I like the options-object pattern for any function with more than two or three parameters. Call sites are self-documenting, like fetchUsers({ page: 2, pageSize: 50 }), argument order doesn't matter, and adding a new option doesn't break existing callers.
A subtle point: defaults are evaluated at call time, left to right, and can reference earlier parameters. So something like function f(a, b = a * 2) works, and function f({ items = [] } = {}) gives a fresh array per call. That's unlike Python, where a mutable default is shared across calls.
Interviewers look for: the difference between the parameter-level default and the field-level defaults, and that neither covers null.
AdvancedWhat exactly does object spread copy, and what does it lose? Predict { ...null } vs [...null].
So { ...null } gives {}, and [...null] throws TypeError: null is not iterable. They use different protocols. Object spread copies own enumerable properties and simply ignores null and undefined. Array spread uses the iterator protocol, so the value must be iterable.
({ ...null }); // {}
({ ...undefined }); // {}
({ ..."hi" }); // { 0: "h", 1: "i" }
[..."hi"]; // ["h", "i"]
[...new Set([1, 1])]; // [1]
[...{ a: 1 }]; // TypeError — plain objects aren't iterable
What object spread copies: own, enumerable properties, both string and symbol keys. What it loses:
class User {
#secret = 1;
constructor() { this.first = "Ana"; this.last = "Rao"; }
get full() { return `${this.first} ${this.last}`; } // on the prototype
greet() { return "hi"; }
}
const copy = { ...new User() };
copy; // { first: "Ana", last: "Rao" } — plain object
copy.greet; // undefined — prototype methods lost
copy.full; // undefined — prototype getter not copied
copy instanceof User; // false
const o = { get now() { return Date.now(); } };
Object.getOwnPropertyDescriptor({ ...o }, "now").get; // undefined — frozen into a value
So: the prototype is gone, which means methods and inherited getters are gone. Non-enumerable properties and #private fields aren't copied. Own getters are called, and their current value becomes a plain data property. Descriptors like writable: false are reset to normal.
That's why I don't spread class instances, like domain models or Dates, and expect them to still work. For those I'd write a clone() method, or use Object.create(Object.getPrototypeOf(o), Object.getOwnPropertyDescriptors(o)) to keep the prototype and descriptors.
17. The Event Loop
BasicJavaScript is single-threaded. So how does it handle a network request without freezing the page?
So the key is that JavaScript itself is single-threaded, but the environment around it isn't. The engine has one call stack, and the browser or Node does the slow work, like timers, network and disk, on its own threads. When that work finishes, it queues a callback, and the event loop pushes that callback onto the stack once the stack is empty.
Let me show you with a quick example.
console.log("before");
fetch("/api/user").then(() => console.log("got user"));
console.log("after");
// before, after, ... (later) got user
Here, what happens is fetch hands the request off to the browser's networking layer and returns a promise right away. My code keeps running and logs "after". When the response arrives, the browser queues the .then callback. The event loop's whole job is really just one rule on repeat: if the stack is empty, take the next thing from the queues and run it.
So the mental model I use is: one stack for my code, Web APIs (or libuv in Node) doing the waiting, and queues holding callbacks that are ready to run. Nothing ever interrupts running code. A callback only runs when the stack has fully cleared.
Interviewers look for: the words "call stack", "Web APIs", "queue", and "only when the stack is empty". The next question is almost always about microtasks vs macrotasks.
BasicDoes setTimeout(fn, 0) run the function after zero milliseconds?
No. The delay is a minimum, not a promise. setTimeout(fn, 0) really means "queue this as a task as soon as possible, after the current code and all pending microtasks are done".
setTimeout(() => console.log("timer"), 0);
const start = Date.now();
while (Date.now() - start < 500) {} // busy for 500ms
console.log("loop done");
// loop done (after ~500ms)
// timer
Here the timer expires almost immediately, but its callback can't run until the stack is empty, so it waits the full 500 milliseconds behind my loop. A couple of other things push it later too. Browsers clamp nested timers to at least 4ms once you're about five levels deep, and background tabs get throttled to around once a second or even less.
Where I've actually used setTimeout(fn, 0) is to defer work until after the current task, for example letting the browser paint a loading spinner before starting something heavy. But for "run right after this code" I'd use queueMicrotask, and for "run before the next paint" I'd use requestAnimationFrame. Each of those says what I mean more precisely.
IntermediateWhat's the difference between microtasks and macrotasks, and what goes in each queue?
So both are queues of callbacks waiting to run, but they have very different priority. After every macrotask, the engine drains the entire microtask queue, including any microtasks added along the way, before it lets anything else happen, even rendering.
There are really two lists I keep in my head:
- Microtasks: promise reactions (
.then,.catch,.finally), the code after anawait,queueMicrotask, andMutationObservercallbacks. - Macrotasks (tasks):
setTimeout,setInterval, I/O,MessageChannel, and UI events like clicks. The initial script itself is also a task.
So one turn of the loop looks like this: run one task, drain all microtasks, maybe render, then pick the next task.
setTimeout(() => console.log("task"), 0);
queueMicrotask(() => {
console.log("micro 1");
queueMicrotask(() => console.log("micro 2")); // still runs before "task"
});
// micro 1, micro 2, task
One gotcha I watch for is that since microtasks keep draining, a microtask that keeps scheduling another microtask will starve the page. No clicks and no paints. A recursive setTimeout is harmless by comparison, because it yields between every run.
Interviewers look for: "drain all microtasks, then one macrotask". Saying "microtasks run first" without the "all of them, then one task" detail is a common half-answer.
IntermediatePredict the output, and walk me through it.
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve()
.then(() => console.log("p1"))
.then(() => console.log("p2"));
queueMicrotask(() => console.log("micro"));
console.log("end");
So the output is start, end, p1, micro, p2, timeout. Let me walk through it the way I'd trace it.
start
end
p1
micro
p2
timeout
First, the synchronous code runs: "start", then the timer gets registered and its callback goes to the task queue. Promise.resolve().then(p1) puts p1 in the microtask queue right away, because the promise is already resolved. But the second .then is attached to a different promise, the one p1 returns, and that one isn't resolved yet. Then queueMicrotask adds "micro" behind p1. Then "end".
Now the stack is empty, so we drain microtasks. p1 runs, and that resolves its promise, which queues p2 at the back of the queue, behind "micro". So it's p1, micro, p2. Only after the microtask queue is fully empty does the timer run.
The part people usually get wrong is thinking p1 and p2 run back to back. Every .then in a chain is its own separate microtask, so other microtasks can slip in between them.
Interviewers look for: the explanation that each .then is scheduled only when the previous one settles. Getting the answer right with no reasoning counts for much less.
IntermediateWhere does requestAnimationFrame fit into the event loop?
So requestAnimationFrame callbacks run as part of the rendering step, right before the browser does style, layout and paint for the next frame. It isn't a microtask, and it isn't a normal task. It runs once per frame, usually at the display's refresh rate, and not at all in a hidden tab.
The order within a loop turn is roughly: a task, then all microtasks, then, if it's time for a frame, the rAF callbacks, then style, layout and paint.
// Animating a progress bar smoothly
function step(timestamp) {
const progress = Math.min((timestamp - start) / 1000, 1);
bar.style.transform = `scaleX(${progress})`;
if (progress < 1) requestAnimationFrame(step);
}
const start = performance.now();
requestAnimationFrame(step);
The reason I'd reach for rAF here instead of setInterval(fn, 16) is that a timer isn't in sync with the screen. It can fire twice in one frame or miss a frame, and that's what makes animations stutter. rAF gives me exactly one callback per frame, with a high-precision timestamp, and it pauses automatically in background tabs, which saves battery.
One practical trick I've used is batching DOM reads and writes. I read the measurements first, then do all my writes inside a rAF, so I don't cause repeated forced layouts.
Interviewers look for: "before paint, once per frame". A good follow-up answer: a rAF scheduled inside a rAF callback runs on the next frame, not the current one.
AdvancedHarder one, with async/await mixed in. What's the output?
async function a() {
console.log("a1");
await b();
console.log("a2");
}
async function b() {
console.log("b");
}
console.log("s1");
setTimeout(() => console.log("t"), 0);
a();
new Promise((res) => {
console.log("p");
res();
}).then(() => console.log("then"));
console.log("s2");
So the output is s1, a1, b, p, s2, a2, then, t.
The things I'm tracking are, first, that an async function runs synchronously until its first await. So calling a() logs "a1" right away, and it also calls b(), which logs "b" synchronously too. Then await pauses a, and the rest of it, "a2", is queued as a microtask once b's promise resolves, which it already has.
Second, the promise executor runs synchronously, so "p" logs immediately. Calling res() queues "then" behind "a2". Then "s2".
Now we drain the microtasks: "a2", then "then". The timer comes last.
One nuance I'd mention is that this depends on the ES2019 change to await. Awaiting a native promise now takes just one microtask tick. In older engines it took three, so "then" used to print before "a2". If b returned a non-native thenable, the ordering would shift again. So in real code I never rely on the exact interleaving between unrelated async chains. If order matters, I make it explicit with await.
Interviewers look for: "an async function runs synchronously until the first await". That one fact explains most async/await ordering puzzles.
AdvancedA user says the page freezes for a second when they click "Export". How do you think about blocking the main thread, and how would you fix it?
So a freeze like that almost always means a long task. Some synchronous work holds the call stack, and since rendering and input handling share that one thread, nothing else can happen until it finishes. Anything over 50ms counts as a long task, and it hurts INP.
First I'd confirm it in the Performance panel. I'd look for the red long-task bar and see which function owns it. Then there are really three fixes, depending on the work:
- Chunk it and yield if it has to touch the DOM or state. That way the browser gets to handle input and paint between chunks.
- Move it to a Web Worker if it's pure computation, like building a big CSV, parsing, or hashing.
- Do less: virtualize the list, memoize, or avoid a layout thrash inside a loop.
async function exportRows(rows) {
const out = [];
for (let i = 0; i < rows.length; i++) {
out.push(toCsvLine(rows[i]));
if (i % 500 === 0) await yieldToMain();
}
return out.join("\n");
}
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((r) => setTimeout(r, 0));
}
One gotcha I've run into is trying to yield with await Promise.resolve(). That doesn't help at all, because it only yields to the microtask queue, and the browser still can't paint. You need a real task boundary, like setTimeout, MessageChannel, or scheduler.yield(), which also keeps your continuation near the front of the queue.
Interviewers look for: that you know microtask yielding doesn't unblock rendering, and that you know when a Worker is the right answer (no DOM access, but the data has to be copied or transferred).
AdvancedHow is Node's event loop different from the browser's?
So the core idea is the same, one thread and microtasks drained between callbacks, but Node's loop, which is libuv, is split into named phases, and it has two scheduling tools browsers don't have: process.nextTick and setImmediate. There's also no rendering step.
The phases go in order: timers (expired setTimeout and setInterval), pending callbacks, poll (waiting for and running I/O), check (setImmediate), and close callbacks. Since Node 11, microtasks drain after each callback, which matches browsers.
const fs = require("node:fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
});
// nextTick, promise, immediate, timeout
Here, what happens is that nextTick has its own queue, which runs even before promise microtasks. Then, because we're inside an I/O callback in the poll phase, the check phase comes next, so setImmediate beats the timer. If I ran those same two lines at the top level of a script instead, the order of setTimeout and setImmediate would be non-deterministic, because it depends on how fast the loop starts.
So in practice I use setImmediate to yield in Node, and I avoid recursive nextTick, because it can starve I/O just like a microtask loop starves rendering in the browser.
Interviewers look for: nextTick before promises, and knowing the setTimeout vs setImmediate order is only guaranteed inside an I/O callback.
18. Callbacks
BasicWhat is a callback? Are callbacks always asynchronous?
So a callback is just a function I pass to another function so that it can call it later, when it's done or for each item. And no, they're not always async. That's a really common misconception.
// Synchronous callback: runs right now, during map
const doubled = [1, 2, 3].map((n) => n * 2);
// Asynchronous callback: runs later, from the event loop
button.addEventListener("click", () => console.log("clicked"));
setTimeout(() => console.log("later"), 1000);
With map, forEach, sort or filter, the callback runs synchronously, inside the call, before the next line. With event listeners, timers, or Node's fs.readFile, the function returns right away, and the callback runs on a later turn of the event loop.
It matters because it changes how I reason about the code. For example, try/catch around a setTimeout won't catch an error thrown inside its callback, because by the time the callback runs, that try block is long gone from the stack.
Callbacks are possible in the first place because functions are first-class values in JavaScript. I can pass them around just like I pass numbers or objects.
IntermediateWhat's actually wrong with "callback hell"? Is it just the indentation?
So the indentation is the visible symptom, but I'd say the real problems are error handling and something called inversion of control.
getUser(id, (err, user) => {
if (err) return handle(err);
getOrders(user.id, (err, orders) => {
if (err) return handle(err);
chargeCard(orders.total, (err, receipt) => {
if (err) return handle(err);
sendEmail(user.email, receipt, () => {});
});
});
});
First, errors don't flow anywhere by themselves. I have to check err at every level, and if I forget one, that failure just disappears. I can't wrap the whole thing in a single try/catch.
The deeper issue is inversion of control. When I pass my callback to chargeCard, which might be a third-party SDK, I'm trusting it to call my function exactly once, not zero times, not twice, and not synchronously one time and async the next. If it calls me twice, I charge the customer twice. I've handed control of my program's flow to code I don't own.
Promises fix exactly that. A promise settles once, and only once, it's always async, and errors propagate down the chain to one .catch. And with async/await, that whole pyramid becomes four flat lines inside one try.
Interviewers look for: the phrase "inversion of control" and the trust problem. Answering only "it's hard to read" is the junior answer.
IntermediateWhat's the error-first callback convention? This code has a bug. Can you spot it?
function readConfig(path, cb) {
fs.readFile(path, "utf8", (err, text) => {
if (err) cb(err);
cb(null, JSON.parse(text));
});
}
So the Node convention is that the callback's first argument is always an error, or null if everything worked, and the results come after that. That way every caller handles errors the same way: (err, data) => { if (err) ... }.
The bug here is a missing return. If the read fails, we call cb(err) and then fall through and call cb a second time, with JSON.parse(undefined), which actually throws a SyntaxError inside the fs callback. That can crash the process. There's a second bug too. If the file exists but has invalid JSON, JSON.parse throws, and that exception never reaches the caller at all.
function readConfig(path, cb) {
fs.readFile(path, "utf8", (err, text) => {
if (err) return cb(err);
let config;
try {
config = JSON.parse(text);
} catch (e) {
return cb(e);
}
cb(null, config); // outside the try, so a throw in cb isn't mistaken for a parse error
});
}
That last detail matters. If I called cb inside the try and the caller's code threw an error, my catch would call cb a second time. So in practice I always return cb(err), and I keep the success call outside the try.
Interviewers look for: the "callback called twice" bug. It's the classic error-first mistake, and it's exactly the guarantee promises give you for free.
IntermediateWhy is it bad for a function to call its callback synchronously sometimes and asynchronously at other times?
So this is what people call "releasing Zalgo". If a function is sometimes sync and sometimes async, the caller can't predict what order their own code runs in, and that leads to bugs that only show up on certain code paths.
The classic case is a cache.
const cache = new Map();
function getUser(id, cb) {
if (cache.has(id)) return cb(cache.get(id)); // sync!
fetchUser(id).then((u) => { cache.set(id, u); cb(u); }); // async
}
let ready = false;
getUser(1, (u) => console.log("ready is", ready));
ready = true;
// First call: "ready is true". Cached call: "ready is false".
Here, what happens is that the first time, the callback runs after ready = true. But once the value is cached, it runs before that line. The same code logs two different things depending on the cache state. In a UI, that could mean a setState before the component is ready, or a listener attached too late.
The fix is to always be async. I'd defer the cached path with queueMicrotask(() => cb(cache.get(id))). Or better, I'd just return a promise, since promises are guaranteed to call .then handlers asynchronously, even when they're already resolved. So in practice this is one more reason I design APIs around promises.
AdvancedImplement promisify: turn an error-first callback function into one that returns a promise.
So the idea is to return a wrapper that creates a promise, calls the original function with the same arguments plus one extra callback at the end, and resolves or rejects based on that callback's error-first arguments.
function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
fn.call(this, ...args, (err, value) => {
if (err) reject(err);
else resolve(value);
});
});
};
}
const readFile = promisify(fs.readFile);
const text = await readFile("config.json", "utf8");
A few details I'm careful about. I use a regular function, not an arrow, and fn.call(this, ...), so if someone promisifies a method like db.query and calls it as obj.query(), this is still correct. And since a promise can only settle once, even a badly behaved API that calls back twice can't resolve it twice. That's a nice safety bonus.
One limitation is that callbacks with multiple success values, like (err, a, b), lose everything after the first one. Node's built-in util.promisify handles that with a custom symbol, util.promisify.custom. In real Node code I'd just use util.promisify, or better, the promise APIs that already exist, like fs/promises.
Interviewers look for: preserving this, spreading args, and the "extra callback goes last" detail. A common follow-up is "what about methods?", which is why you use fn.call(this, ...).
19. Promises
BasicWhat is a promise, and what states can it be in?
So a promise is an object that stands in for a value that isn't ready yet. It's a placeholder for the eventual result of some async work. It has three states: pending, then either fulfilled with a value or rejected with a reason. Once it's fulfilled or rejected, we call it settled, and it can never change again.
const p = new Promise((resolve, reject) => {
resolve("first");
resolve("second"); // ignored
reject(new Error("nope")); // ignored
});
p.then((v) => console.log(v)); // "first"
That "settles once" rule is really the whole point. It's the guarantee callbacks never gave me. My handler runs exactly once, and it always runs asynchronously, even if the promise was already resolved when I attached it.
The other thing I like to point out is that a promise is eager. The executor function runs immediately, synchronously, when you call new Promise. So a promise doesn't start the work, it just represents work that's already started. That's why fetch(url) fires the request the moment you call it, whether or not you ever await it.
Interviewers look for: "settled is permanent" and "the executor runs synchronously". Some ask what "resolved" means: it means locked in, possibly to another promise that's still pending, so it isn't the same as fulfilled.
IntermediateThis chain logs undefined for the orders. Why?
getUser(id)
.then((user) => {
getOrders(user.id);
})
.then((orders) => {
console.log(orders); // undefined
});
So the bug is the missing return. Every .then returns a new promise, and that new promise resolves with whatever the handler returns. This handler has a block body with no return, so it returns undefined. The next .then runs right away with undefined, while getOrders is still in flight, and nobody is listening to it anymore.
getUser(id)
.then((user) => getOrders(user.id)) // returning the promise
.then((orders) => console.log(orders));
Now, because I return a promise, the chain waits for it and passes along its fulfilled value. That's the rule I keep in mind: return a plain value, and the next step gets it. Return a promise, and the chain waits for it and unwraps it. Throw, and the chain jumps to the next catch.
There's a sneaky second problem in the buggy version. If getOrders rejects, that rejection isn't connected to the chain, so a .catch at the end would never see it. It becomes an unhandled rejection. A lint rule like promise/always-return, or just using async/await, catches this whole class of bug.
Interviewers look for: "then returns a new promise" and the floating-promise consequence for error handling, not just the missing value.
IntermediatePredict the output. How do errors propagate through a chain?
Promise.resolve(1)
.then((x) => x + 1)
.then((x) => { throw new Error(String(x * 10)); })
.then((x) => console.log("skipped", x))
.catch((e) => { console.log("caught", e.message); return 5; })
.finally(() => { console.log("finally"); return 99; })
.then((x) => console.log("after", x));
So this logs caught 20, then finally, then after 5.
Walking through it: 1 becomes 2, then we throw an Error with the message "20". A rejection skips every .then fulfillment handler until it finds a rejection handler, so "skipped" never runs. The .catch handles it, and since it returns 5, the chain is recovered and fulfilled again. That's something people forget: catch doesn't end the chain, it converts a rejection back into a success.
Then finally runs, but its return value is ignored. It passes through the original value, 5, not 99. The only way finally changes the outcome is by throwing or returning a rejected promise.
A related thing I'd point out is .then(ok, fail) versus .then(ok).catch(fail). In the first form, fail won't catch an error thrown inside ok. So I almost always put a .catch at the end.
Interviewers look for: "catch recovers the chain" and "finally passes the value through". Candidates often think after prints 99 or undefined.
IntermediateWhen would you use Promise.all, allSettled, race and any?
So they all take a list of promises and differ in when they settle, and what counts as failure. I think of them by the question each one answers:
all: "I need every one of these." It fulfills with an array in the input order, and it rejects as soon as any one rejects.allSettled: "Tell me how each one went." It never rejects, and gives me{status, value}or{status, reason}for each.race: "Whoever settles first wins," success or failure.any: "Give me the first success." It only rejects, with anAggregateError, if all of them fail.
// Dashboard: page needs both, fail fast
const [user, perms] = await Promise.all([getUser(), getPerms()]);
// Optional widgets: show whatever loaded
const results = await Promise.allSettled(widgets.map(load));
const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
// Same asset from several CDNs, take the fastest success
const img = await Promise.any(mirrors.map((m) => fetch(m + "/logo.png")));
One gotcha with all is that "fail fast" doesn't mean the other requests stop. They keep running, and their results are just ignored. If I actually need to stop them, I pass an AbortController signal and abort it in the catch.
And I use race mostly for timeouts. For "fastest mirror", any is better, because with race one quick 404-style failure would win.
Interviewers look for: the race vs any distinction, and knowing that Promise.all doesn't cancel anything.
AdvancedWhat's the "promise constructor antipattern"? What's wrong with this code?
function getJson(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then((res) => res.json())
.then((data) => resolve(data));
});
}
So the antipattern is wrapping something that's already a promise in new Promise. It adds nothing, and it usually breaks error handling, which is exactly what happened here.
In this code, if the network fails or the body isn't valid JSON, the inner chain rejects, but nobody calls reject. So the promise I returned stays pending forever. The caller's await just hangs. There's no error and no timeout, and that's a really painful bug to track down in production.
async function getJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Now any rejection flows to the caller automatically. I also added the res.ok check, because fetch only rejects on network failure, not on a 404 or a 500.
So my rule is that I only use new Promise to wrap something that is not promise-based, like a callback API, an event, or setTimeout. Anything that already gives me a promise, I just return or await.
Interviewers look for: the "never settles" consequence. A nice bonus is noting that fetch doesn't reject on HTTP errors.
AdvancedWhat's a thenable, and why does it matter?
So a thenable is any object with a then method. Promises are designed to work with those, not just with real Promise instances. When you resolve a promise with a thenable, or return one from .then, or await one, the engine calls its then method with resolve and reject, and adopts whatever it settles to.
const thenable = {
then(resolve) {
setTimeout(() => resolve(42), 100);
},
};
const value = await thenable; // 42, after 100ms
// Surprise: a plain object with a "then" key gets "unwrapped" too
const config = { then: "later" }; // not a function, so this one is fine
const weird = { then(cb) { cb("hijacked"); } };
console.log(await Promise.resolve(weird)); // "hijacked"
It matters for two reasons. The good one is interop. Before native promises, libraries like jQuery, Bluebird and Q all had their own promise types, and thenable detection is what let them all work together. Some query builders, like Knex or Mongoose queries, are thenables, which is why you can await them directly.
The gotcha is that if I ever resolve with an object that happens to have a then method, it gets treated as a promise and never comes through as a plain value. It's rare, but I've seen it with an ORM model where a record had a field called then. It's also why a module namespace object exporting a function called then causes trouble with dynamic import().
AdvancedWhat's an unhandled rejection, and how should an app deal with them?
So an unhandled rejection is a promise that rejects with no rejection handler attached by the time the current microtask queue is drained. The runtime notices that nobody is going to handle the error, and it reports it.
async function save() { throw new Error("db down"); }
save(); // no await, no .catch: unhandled rejection
// Browser: global safety net for logging
window.addEventListener("unhandledrejection", (e) => {
reportToSentry(e.reason);
});
// Node
process.on("unhandledRejection", (reason) => {
logger.error(reason);
process.exit(1);
});
The behavior differs a lot by platform. In the browser, it's just logged to the console, and the page keeps going. In Node, since version 15, the default is to crash the process. That surprises people who upgrade and suddenly their server restarts in a loop.
In practice I see the global handlers as a safety net for logging, not a strategy. The real fix is not leaving promises floating. Every promise should be awaited, returned, or given an explicit .catch. I turn on the @typescript-eslint/no-floating-promises rule for that. For fire-and-forget calls, like analytics, I write void track(event).catch(logError), so it's obvious I chose not to wait.
Interviewers look for: the Node crash-by-default behavior, and that you treat global handlers as logging only. A follow-up could be the rejectionhandled event, for a handler attached late.
20. async / await
BasicWhat does the async keyword actually do to a function?
So async does two things. The function always returns a promise, and it lets me use await inside it. Whatever I return becomes the fulfillment value, and whatever I throw becomes the rejection.
async function getCount() {
return 5;
}
getCount(); // Promise {<fulfilled>: 5}, not 5
getCount().then(console.log); // 5
async function fail() {
throw new Error("boom");
}
fail(); // rejected promise, doesn't throw here
fail().catch((e) => console.log(e.message)); // "boom"
Here, what happens is that return 5 is wrapped, as if I'd written return Promise.resolve(5). That's the bug I see most with juniors. They call an async function and then use its result like a plain value, like if (isAdmin()), which is always truthy because a promise object is truthy.
And the throw part matters because the function never throws at the call site. Even an error on the very first line becomes a rejected promise. So if I want to catch it, I have to await it or attach .catch. A try/catch around a plain, non-awaited call won't see it.
IntermediateWhat does await desugar into? Does it block the thread?
So no, await doesn't block the thread. It only pauses that one function. Under the hood, the function is split at each await, and everything after it becomes a continuation that runs as a microtask when the awaited promise settles. Meanwhile the event loop is free to run other code.
Roughly, these two are equivalent:
async function load() {
const user = await getUser();
const posts = await getPosts(user.id);
return posts.length;
}
// roughly the same as:
function load() {
return getUser()
.then((user) => getPosts(user.id))
.then((posts) => posts.length);
}
The real implementation is closer to a generator driven by promises. Before async existed, libraries like co did exactly that with function* and yield. The engine saves the function's local variables and position, returns control to the caller, and resumes later with the resolved value. Or, if the promise rejected, it throws the error at the await line, which is why try/catch works.
A small detail is that await on a non-promise, like await 5, still pauses for one microtask tick. So it's never truly synchronous.
Interviewers look for: "pauses the function, not the thread", and the connection to generators. Senior interviewers may ask you to write the generator-runner version.
IntermediateHow do you handle errors with async/await? Is try/catch around everything the right approach?
So try/catch is the natural fit, since a rejected await throws right at that line. But I don't wrap everything. I catch where I can actually do something about the error, and I let it bubble up otherwise.
async function loadProfile(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (err.name === "AbortError") return null; // expected, ignore
throw new ProfileError("Could not load profile", { cause: err });
}
}
There are really three cases I think about. If I can recover, like a fallback value or a retry, I catch locally. If I can add context, I catch, wrap with { cause }, and rethrow. If I can do neither, I don't catch at all, and a boundary higher up, like a route handler or a React error boundary, deals with it.
What I avoid is catching and only logging, because then the caller thinks it succeeded and gets undefined. And when one step is optional, I sometimes use .catch inline, which reads nicely:
const prefs = await getPrefs().catch(() => DEFAULT_PREFS);
Interviewers look for: catching at the right layer, and rethrowing with cause. A red flag is empty catch blocks, or catch-and-log that swallows the failure.
IntermediateA dashboard takes 3 seconds to load, but each API call takes about 1 second. What's likely wrong?
So my first guess is that the calls are being awaited sequentially, even though they don't depend on each other. Each await waits for the previous one to finish before the next request even starts.
// Sequential: ~3s
const user = await getUser();
const stats = await getStats();
const alerts = await getAlerts();
// Parallel: ~1s (as slow as the slowest call)
const [user, stats, alerts] = await Promise.all([
getUser(),
getStats(),
getAlerts(),
]);
The fix is to start all the requests first, and then wait for them together. Promise.all is the clean way to do that. The key insight is that calling the function is what starts the request. The await just waits. So you could even write const p1 = getUser(); const p2 = getStats(); and await them later, but I prefer Promise.all, because with the manual version, if p2 rejects while I'm still awaiting p1, it can briefly look like an unhandled rejection.
Of course, if getStats needs the user's id, that one really does have to wait. So I'd draw the dependency graph and parallelize only what's independent. And if one of them is optional, I'd use allSettled, so a failing alerts widget doesn't blank the whole dashboard.
Interviewers look for: the insight that calling starts the work and await only waits, plus awareness of dependencies between calls.
IntermediateWhy doesn't this wait for all the saves before logging "done"?
async function saveAll(items) {
items.forEach(async (item) => {
await save(item);
});
console.log("done");
}
So the problem is that forEach doesn't know anything about promises. It calls my async callback for each item, each call returns a promise, and forEach just throws those promises away. So the loop finishes right away, "done" logs immediately, and all the saves are still running. Any errors become unhandled rejections too.
The fix depends on whether I want the saves one at a time or all together:
// One at a time, in order
for (const item of items) {
await save(item);
}
// All at once
await Promise.all(items.map((item) => save(item)));
for...of respects await, because the await is directly in my function, not in a callback. map works for the parallel version because it returns the promises, so I can hand them to Promise.all. The same trap applies to filter, some and reduce with async callbacks. filter is especially sneaky, because a promise is always truthy, so it keeps everything.
In practice I pick sequential when order matters or the server can't handle the load, and parallel otherwise. For a thousand items, I'd use a concurrency limit, not either extreme.
Interviewers look for: the explanation of why (forEach ignores return values), not just the fix. The async filter follow-up is a good bonus.
AdvancedIs there any difference between return promise and return await promise in an async function?
So most of the time they behave the same, but there's one case where it really matters, and that's inside a try/catch.
async function withoutAwait() {
try {
return fetchData(); // rejection escapes the try
} catch {
return "fallback"; // never runs
}
}
async function withAwait() {
try {
return await fetchData(); // rejection is thrown HERE, inside try
} catch {
return "fallback"; // runs on failure
}
}
Here, what happens is that without await, I'm returning the promise itself. The function exits the try block right away, and the rejection happens later, outside it, so the catch never sees it. With return await, the function waits for the promise while still inside the try, so a rejection is thrown there and caught. The same applies to finally. Without await, cleanup runs before the work actually finishes.
There's also a smaller benefit, which is stack traces. With await, V8's async stack traces include this function, so the error shows where it came through.
The old advice that return await is "redundant and slower" is out of date. The extra tick was removed in modern engines. So my rule is to always use return await inside try, and it's fine elsewhere too. The newer typescript-eslint return-await rule actually recommends it in try/catch.
Interviewers look for: the try/catch case specifically. Saying "no difference" or "just redundant" is the trap answer.
AdvancedAn async function throws on its very first line. Does the caller's try/catch catch it?
async function validate(input) {
if (!input) throw new Error("empty");
await check(input);
}
try {
validate("");
} catch (e) {
console.log("caught", e.message);
}
So no, "caught" never prints. Even though the throw happens synchronously, before any await, an async function turns every throw into a rejected promise. The call returns normally, the try block exits without an error, and we end up with an unhandled rejection. The fix is to await validate("") inside an async context.
The reverse case is the one that bites people with regular functions that return promises:
function load(id) {
if (typeof id !== "number") throw new TypeError("bad id"); // sync throw!
return fetch(`/items/${id}`);
}
load("x").catch(handle); // catch never attaches, TypeError is thrown right here
That function sometimes throws synchronously and sometimes rejects, so callers need both styles of error handling. That's a small version of the "Zalgo" problem. So my practice is that if a function returns a promise, I make it async, so all failures come out the same way, as rejections.
Interviewers look for: "async functions never throw synchronously". The best candidates also mention the non-async promise-returning variant.
AdvancedWhat is top-level await, and what are the trade-offs of using it?
So top-level await lets me use await directly in the body of an ES module, with no async function wrapping it. It only works in modules: type="module" scripts, .mjs files, or Node with "type": "module". It doesn't work in classic scripts or CommonJS.
// config.js
const res = await fetch("/config.json");
export const config = await res.json();
// app.js
import { config } from "./config.js"; // waits until config.js finishes
console.log(config.apiUrl);
The way it works is that the module's evaluation becomes async. Any module that imports it waits until it finishes before running its own body. That's great for things like loading config, connecting to a database before a server starts, or a conditional dynamic import() for a polyfill.
The trade-off is that it's a blocking point in the module graph. If I put a slow network call at the top level of a shared utility module, every module that imports it, even indirectly, waits for it. That can delay the whole app's startup. Sibling modules that don't depend on it can still evaluate, but anything downstream is stuck. And if it rejects, the import fails for everyone.
So in practice I use it at the entry point or in leaf modules for one-time setup, and I keep it out of widely shared libraries. For anything optional, I'd export a promise, or an init function, instead.
21. Async Patterns
IntermediateDebounce vs throttle: when would you use each? Give me a real scenario.
So both limit how often a function runs, but in different ways. Debounce waits for a pause. It only runs once the events have stopped for some amount of time. Throttle runs at a steady rate, at most once every N milliseconds, while the events keep coming.
The way I choose between them is to ask: do I care about the final state, or do I need steady updates while it's happening?
function debounce(fn, ms) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
function throttle(fn, ms) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= ms) {
last = now;
fn.apply(this, args);
}
};
}
In my last project, we had a product search box that fired an API call on every keystroke. Typing "headphones" meant ten requests. I debounced it by 300ms, so it only searched once the user paused, which cut the requests by about 90%. Debounce also fits autosave and resizing a window before recalculating a layout.
On the same page, we had an infinite-scroll listener that checked whether you were near the bottom. Debouncing that would be wrong, because nothing would load until you stopped scrolling. So I throttled it to every 200ms, which gives steady checks while you scroll.
Interviewers look for: the "final state vs steady rate" framing. Expect the follow-up: "add a leading-edge option" or "make throttle fire a final trailing call".
IntermediateHow would you add a timeout to a promise? What's the catch with the usual approach?
So the classic approach is Promise.race between the real work and a promise that rejects after N milliseconds. Whichever settles first wins.
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
const data = await withTimeout(fetch("/api/report"), 5000);
Two details I'm careful about. First, I clear the timer in finally. Otherwise, in Node, a pending 5-second timer can keep the process alive after everything else is done, and in a busy server those timers pile up.
The bigger catch is that race only stops waiting. It doesn't stop the work. The fetch keeps going in the background, using bandwidth, and maybe even finishing a write on the server. Promises just aren't cancellable by themselves.
So for fetch specifically, I'd use an abort signal instead, which actually cancels the request:
const res = await fetch("/api/report", { signal: AbortSignal.timeout(5000) });
// rejects with a "TimeoutError" DOMException, and the request is really cancelled
Interviewers look for: clearing the timer, and understanding that race doesn't cancel anything. Mentioning AbortSignal.timeout shows you know current APIs.
IntermediateWrite a retry helper with exponential backoff. What should and shouldn't be retried?
So the idea is to try the operation, and if it fails with a temporary error, wait and try again, doubling the wait each time, up to a limit. I also add random jitter, so a thousand clients that failed at the same moment don't all retry at the same moment too.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function retry(fn, { retries = 3, base = 300, shouldRetry = () => true } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt >= retries || !shouldRetry(err)) throw err;
const delay = base * 2 ** attempt; // 300, 600, 1200...
await sleep(delay / 2 + Math.random() * delay / 2); // jitter
}
}
}
const data = await retry(() => getJson("/api/feed"), {
shouldRetry: (e) => e.status === undefined || e.status >= 500 || e.status === 429,
});
Notice I pass a function, not a promise. A promise is already running and settles once, so retrying the same promise would just give me the same failure. I need to call fn again to make a new request.
On what to retry: network errors, 5xx, 429 (ideally honoring the Retry-After header), and timeouts. Not 4xx like 400, 401 or 404, because the same request will fail the same way. And I'm very careful with non-idempotent requests, like a POST that charges a card. If the first attempt actually succeeded but the response was lost, a retry charges twice. For those, I'd need an idempotency key.
Interviewers look for: passing a function (not a promise), jitter, and the idempotency concern. That last one really separates senior answers.
IntermediateHow do you cancel an in-flight request with AbortController?
So an AbortController gives me a signal I pass into the async operation, and a method, abort(), that triggers it. fetch watches that signal, so aborting really cancels the request, and the fetch promise rejects with an AbortError.
The most common place I use it is in a React effect, cancelling the request when the component unmounts or the id changes.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [id]);
Here, what happens is that when id changes, the cleanup aborts the old request before the new one starts. So I never set state from a stale response. I also filter out AbortError, because a cancellation isn't a real error, and I don't want to show an error message for it.
The nice part is that the signal is a general standard, not only for fetch. addEventListener accepts { signal } to remove listeners, a lot of Node APIs accept it, and I can make my own functions cancellable by checking signal.aborted or listening for its "abort" event. There's also AbortSignal.any([a, b]) to combine, say, a user's cancel button with a timeout.
Interviewers look for: the cleanup-function pattern and ignoring AbortError. A good follow-up: one controller can only abort once, so you need a new one per request.
AdvancedIn a search-as-you-type box, results sometimes show for an older query. Why, and how do you fix it?
So that's a race condition. Responses don't come back in the order the requests were sent. If I type "re" then "react", the "re" request might be slower, because it's a broader query, so it arrives after "react" and overwrites the correct results with stale ones.
input.addEventListener("input", async (e) => {
const results = await search(e.target.value);
render(results); // whichever response arrives LAST wins, not the latest query
});
Debouncing reduces how often it happens, but it doesn't fix it. Two requests can still overlap. There are really two fixes, and I usually combine them:
let controller;
let latestId = 0;
input.addEventListener("input", debounce(async (e) => {
controller?.abort(); // 1. cancel the previous request
controller = new AbortController();
const id = ++latestId; // 2. tag this request
try {
const results = await search(e.target.value, { signal: controller.signal });
if (id === latestId) render(results); // ignore anything stale
} catch (err) {
if (err.name !== "AbortError") showError(err);
}
}, 250));
Aborting saves the network and server work. The id check is a belt-and-braces guard, because abort might happen after the response has arrived but before it's parsed, or search might not support signals.
In practice, libraries like TanStack Query solve this for me, since results are keyed by the query, so a stale response just fills a cache entry nobody is looking at. But I think it's important to understand the bug, because it shows up anywhere async results land in shared state.
Interviewers look for: saying clearly that debounce alone doesn't fix it. Expect "how would you do this in a React effect?" as a follow-up (an ignore flag or abort in cleanup).
AdvancedYou need to upload 500 files, but no more than 5 at a time. How would you implement that?
So neither extreme works here. Doing them one at a time is too slow, and Promise.all on 500 fires everything at once, which overwhelms the browser's connection limit and probably the server too. What I want is a pool: always keep 5 in flight, and start the next one the moment any finishes.
The trick I use is to start N "workers" that all pull from a shared index.
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++; // claim an index (safe: single-threaded)
results[i] = await fn(items[i], i);
}
}
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
await Promise.all(workers);
return results; // same order as the input
}
await mapLimit(files, 5, (file) => upload(file));
Here, what happens is that each worker is a loop that grabs the next index and awaits the upload. Since JavaScript is single-threaded, next++ can't have two workers claim the same index. There's no await between reading and incrementing it. And I write into results[i], so the output order matches the input even though uploads finish out of order.
One design choice is error handling. As written, the first failure rejects Promise.all, but the other workers keep going. For uploads I'd probably catch per item and collect {status, value}, like allSettled, so one bad file doesn't hide the other 499 results. In production I'd reach for p-limit, but I'm comfortable writing it.
Interviewers look for: keeping the pool full (not batching in groups of 5, which waits on the slowest in each batch), preserving order, and a deliberate choice about errors.
AdvancedThree components on the same page all request /api/me at startup. How would you make it one network call?
So the trick is to cache the promise, not the result. If I only cached the result, all three calls would miss the cache, since none of them has finished yet, and I'd still send three requests. If I cache the in-flight promise, the second and third callers just get the same promise back.
const inflight = new Map();
function dedupedFetch(url) {
if (inflight.has(url)) return inflight.get(url);
const p = fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.finally(() => inflight.delete(url)); // allow a fresh fetch next time
inflight.set(url, p);
return p;
}
// All three share one request
const [a, b, c] = await Promise.all([
dedupedFetch("/api/me"), dedupedFetch("/api/me"), dedupedFetch("/api/me"),
]);
A couple of choices I made on purpose. I delete the entry in finally, so a failed request isn't cached forever. Otherwise one network blip would break the page until a reload. And I parse the JSON inside the shared promise. If I shared the raw Response, the first caller to read the body would consume it, and the others would get a "body already used" error.
This is exactly what data libraries like SWR, TanStack Query and Apollo do under the hood, along with keeping the resolved value for a while as a real cache. So in a React app I'd use one of those, but this pattern is handy in plain JS, or for something like a token-refresh call, where several requests hit a 401 at the same moment and should all wait on one refresh.
Interviewers look for: "cache the promise", not the value, and clearing it on failure. The concurrent token-refresh scenario is a very common follow-up.
22. Iterators & Generators
BasicWhat actually makes something iterable, and why doesn't for...of work on a plain object?
So "iterable" is just a protocol: an object is iterable if it has a [Symbol.iterator] method that returns an iterator, and an iterator is anything with a next() method that returns { value, done }. Arrays, strings, Maps and Sets all have that method built in. Plain objects don't, so for...of has nothing to call.
Let me show you what for...of is really doing under the hood.
const arr = ["a", "b"];
const it = arr[Symbol.iterator]();
it.next(); // { value: "a", done: false }
it.next(); // { value: "b", done: false }
it.next(); // { value: undefined, done: true }
const user = { name: "Ada", role: "admin" };
for (const x of user) {} // TypeError: user is not iterable
for (const [key, value] of Object.entries(user)) {
console.log(key, value); // works: entries() gives me an array
}
Here the loop just keeps calling next() until done is true. The same protocol powers spread, array destructuring, Array.from, new Map(iterable) and Promise.all. That's why they all accept a Set or a generator, not just arrays.
For plain objects, I use Object.entries, Object.keys or Object.values. Or I use for...in, but that also walks inherited enumerable keys, so I rarely use it.
Interviewers look for: the distinction between iterable (has Symbol.iterator) and iterator (has next), and knowing that spread, destructuring and Promise.all all consume the same protocol.
BasicWhat is a generator, and what happens when you call a generator function?
So a generator is a function that can pause itself at each yield and resume later from exactly the same spot, with its local variables intact. The surprising part is that calling it doesn't run any of the body. It just hands you back a generator object, which is both an iterator and an iterable.
function* countdown(n) {
console.log("starting");
while (n > 0) yield n--;
return "liftoff";
}
const gen = countdown(3); // nothing logged yet
gen.next(); // logs "starting" -> { value: 3, done: false }
gen.next(); // { value: 2, done: false }
gen.next(); // { value: 1, done: false }
gen.next(); // { value: "liftoff", done: true }
[...countdown(3)]; // [3, 2, 1] (the return value is NOT included)
Here, "starting" only logs on the first next(), because that's when the body actually begins. Each next() runs until the next yield and then freezes. One gotcha is that the return value comes back with done: true, and for...of and spread ignore anything that comes with done: true. So "liftoff" never shows up in the array.
Another one I've been bitten by is that a generator object is one-shot. If I spread the same gen twice, the second spread is empty, because it's already exhausted.
IntermediateMake this object iterable so that [...range] gives [1, 2, 3, 4, 5].
So I need to give it a [Symbol.iterator] method. The cleanest way is to make that method a generator, because a generator already implements the whole iterator protocol for me.
const range = {
from: 1,
to: 5,
*[Symbol.iterator]() {
for (let i = this.from; i <= this.to; i++) yield i;
},
};
[...range]; // [1, 2, 3, 4, 5]
for (const n of range) {} // works
const [first, second] = range; // 1, 2
[...range]; // [1, 2, 3, 4, 5] again: a fresh iterator each time
I could write it by hand, returning an object with a next() that tracks a counter, but the generator version has less state to get wrong. It also handles return() for early exits automatically.
The design point I'd call out is that each call to [Symbol.iterator]() returns a new iterator. That's why I can spread range as many times as I like. If I'd returned this and kept the counter on the object itself, the second spread would come back empty. I use the same pattern for things like a LinkedList or a Matrix class, so callers can just use for...of without knowing the internals.
Interviewers look for: the *[Symbol.iterator]() shorthand, and the follow-up of making a class iterable the same way. Returning a fresh iterator per call, rather than a shared stateful one, is the detail that separates strong answers.
IntermediatePredict the output. What does the value passed to next() do?
function* calc() {
const x = yield 1;
console.log("x is", x);
const y = yield x * 2;
return x + y;
}
const it = calc();
console.log(it.next(5));
console.log(it.next(10));
console.log(it.next(3));
console.log(it.next());
So the output is:
{ value: 1, done: false }
x is 10
{ value: 20, done: false }
{ value: 13, done: true }
{ value: undefined, done: true }
The key idea is that the value I pass to next(v) becomes the result of the yield expression where the generator is currently paused. On the very first next(5) the generator hasn't reached any yield yet. It just starts and runs up to yield 1, so that 5 is thrown away.
The second call, next(10), resumes the paused yield 1 with 10, so x is 10. It logs, then pauses at yield 20. The third call sets y to 3 and runs to the return, which gives 13 with done: true. After that the generator is finished and just keeps returning undefined.
This two-way channel is exactly how libraries like co and redux-saga worked. The generator yields a promise, the runner awaits it and passes the result back in with next(result). That's basically async/await built by hand.
Interviewers look for: the "first next() argument is ignored" trap. A common follow-up is it.throw(err), which raises an error at the paused yield so the generator can catch it.
IntermediateHow would you work with an infinite or huge sequence lazily?
So the trick is to never build the array. I chain generators so each value is pulled through the pipeline only when someone asks for it. Then an infinite source is totally fine, as long as something at the end stops pulling.
function* naturals() {
let n = 1;
while (true) yield n++;
}
function* filter(iter, fn) {
for (const x of iter) if (fn(x)) yield x;
}
function* take(iter, n) {
if (n <= 0) return;
for (const x of iter) {
yield x;
if (--n === 0) return;
}
}
[...take(filter(naturals(), n => n % 3 === 0), 4)]; // [3, 6, 9, 12]
// Modern engines ship iterator helpers (ES2025), so this also works:
naturals().filter(n => n % 3 === 0).take(4).toArray(); // [3, 6, 9, 12]
What happens here is that take pulls one value from filter, which pulls from naturals until it finds a multiple of 3. Nothing runs ahead. When take returns, the for...of inside it calls return() on the upstream generators, so they shut down cleanly too.
In a real project I used this to walk a big tree of nested comments. I stopped as soon as I found the first match, without flattening thousands of nodes up front. The trade-off is that generators are a bit slower per item than a plain loop. So for a small array that fits in memory, I'd just use .filter().slice().
IntermediateWhat happens to a generator when you break out of a for...of loop early?
So for...of calls the iterator's return() method when I exit early with break, return or a thrown error. For a generator, that finishes it at the paused yield, so any finally block inside the generator still runs. That's how I get guaranteed cleanup.
function* readLines() {
console.log("open file");
try {
yield "line 1";
yield "line 2";
yield "line 3";
} finally {
console.log("close file");
}
}
for (const line of readLines()) {
console.log(line);
if (line === "line 2") break;
}
// open file
// line 1
// line 2
// close file
Here "line 3" never gets produced, but "close file" still logs because break triggered return(). This matters when the generator is holding a resource, like a file handle, a DB cursor or a WebSocket subscription. I put the teardown in finally and every consumer gets cleanup without having to remember it.
One gotcha: if I call next() manually and just stop calling it, nothing triggers return(). The generator stays suspended forever and finally never runs. So if I'm driving an iterator by hand, I call it.return() myself when I'm done.
AdvancedWhat are async generators and for await...of? Give a real use case.
So an async generator, async function*, can both await and yield, and its next() returns a promise. I consume it with for await...of, which awaits each item in turn. The textbook use case is a paginated API, where I want to treat "all the items" as one stream without knowing how many pages there are.
async function* fetchAllOrders() {
let url = "/api/orders?page=1";
while (url) {
const res = await fetch(url);
const { items, next } = await res.json();
yield* items; // hand out items one by one
url = next; // null on the last page
}
}
for await (const order of fetchAllOrders()) {
if (order.total > 1000) {
console.log("found a big one", order.id);
break; // stops fetching further pages
}
}
What I like here is that it's lazy. The next page is only fetched once the consumer has used up the current one. And if I break early, no more requests go out. Node streams are also async iterables, so for await (const chunk of readStream) is my go-to for reading big files line by line.
One gotcha: for await over an array of already-created promises isn't the same as Promise.all. It awaits them in order, and if a later promise rejects while I'm still waiting on an earlier one, that rejection counts as unhandled for a while. So for "fire N requests in parallel", I still use Promise.all or allSettled. I use for await for things that genuinely arrive one after another.
Interviewers look for: laziness and backpressure (the producer only runs when pulled), and knowing that break cancels further work. The follow-up is often "how would you add a concurrency limit?"
23. Error Handling
BasicDoes finally run if I return inside try? Predict the output.
function a() {
try { return "from try"; }
finally { console.log("finally runs"); }
}
console.log(a());
function b() {
try { throw new Error("boom"); }
finally { return "from finally"; }
}
console.log(b());
So yes, finally always runs. The output is "finally runs", then "from try", then "from finally".
In a, the return value "from try" is computed first and held on to, then finally runs, and then the function actually returns. That's why the log comes before the returned value is printed.
In b things get dangerous. A return inside finally overrides whatever was happening, including a thrown error. So "boom" is silently swallowed and the caller gets "from finally" as if nothing went wrong. The same goes for a return in finally overriding a return in try.
So in practice I only use finally for cleanup, like hiding a spinner, releasing a lock or closing a connection, and I never return or throw from it. Most lint configs flag that with no-unsafe-finally.
Interviewers look for: the "return in finally swallows the error" trap. A common follow-up: does finally run in an async function after an await rejects? Yes, as long as the await is inside the try.
BasicWhy should you always throw Error objects instead of strings?
So JavaScript lets you throw anything, but only an Error object captures a stack trace. That stack trace is the thing that actually lets me find the bug. A thrown string is just a string. There's no stack, no name, and err.message is undefined.
try {
throw "User not found";
} catch (err) {
err.message; // undefined
err.stack; // undefined: where did this come from?
}
try {
throw new Error("User not found");
} catch (err) {
err.message; // "User not found"
err.stack; // "Error: User not found\n at getUser (user.js:12:11) ..."
}
It also breaks everything downstream. My error tracker, Sentry for example, groups errors by type and stack. Code that does err instanceof TypeError or reads err.message falls over. And logging shows up as a bare string with no context.
The same rule applies to promises: I reject with new Error(...), never reject("failed"). In TypeScript the catch variable is unknown for exactly this reason. You genuinely can't assume it's an Error when you're catching from third-party code, so I narrow it with instanceof Error before touching .message.
IntermediateHow and why would you create custom error classes?
So I create custom errors when callers need to react differently depending on what went wrong. A validation error should become a 400 with a field message. A "not found" should become a 404. Anything else is a real bug and should be a 500. Custom classes let me branch on instanceof and carry structured data, instead of parsing message strings.
class AppError extends Error {
constructor(message, options) {
super(message, options); // passes { cause } through
this.name = this.constructor.name;
}
}
class ValidationError extends AppError {
constructor(field, message) {
super(message);
this.field = field;
this.status = 400;
}
}
class NotFoundError extends AppError {
status = 404;
}
// in an Express error handler
function handleError(err, req, res, next) {
if (err instanceof ValidationError) {
return res.status(400).json({ field: err.field, error: err.message });
}
if (err instanceof AppError) {
return res.status(err.status ?? 500).json({ error: err.message });
}
console.error(err);
res.status(500).json({ error: "Something went wrong" });
}
A couple of details I always get right. I call super(message) so message and stack are set up properly. I set name, because that's what shows in the stack trace and in logs. Using this.constructor.name in a base class means every subclass gets the correct name for free.
The trade-off is not going overboard. A handful of classes that map to how the app responds is plenty, not one class per failure.
Interviewers look for: branching on instanceof rather than string-matching message, and setting name. Senior follow-up: instanceof can fail across realms (iframes) or with duplicated package copies, which is why some codebases check a code property instead.
IntermediateWhat is Error.cause, and when do you use it?
So cause is an option, added in ES2022, that lets me wrap a low-level error in a higher-level one without losing the original. Before it existed, people either rethrew the raw database error, which leaks details and loses context, or threw a new error and threw away the real reason.
async function loadInvoice(id) {
try {
return await db.query("SELECT * FROM invoices WHERE id = $1", [id]);
} catch (err) {
throw new Error(`Failed to load invoice ${id}`, { cause: err });
}
}
try {
await loadInvoice(42);
} catch (err) {
console.error(err.message); // "Failed to load invoice 42"
console.error(err.cause.message); // "connection terminated unexpectedly"
}
Here each layer adds the context it knows about, like which invoice, which user or which step. The original error, with its own stack, stays attached. Node's console and most error trackers print the whole cause chain, so in the logs I see the full story from "checkout failed" all the way down to "ECONNRESET".
So in practice I wrap at boundaries, like a repository layer or an API client, where the low-level message stops being meaningful to the caller. I don't wrap at every function, because that just adds noise.
IntermediateThis catch never runs when the request fails. Why?
async function getUser(id) {
try {
return fetchUser(id); // fetchUser returns a promise
} catch (err) {
return { id, name: "Guest" };
}
}
So the bug is the missing await. The try block creates the promise and returns it straight away, which is synchronous and doesn't throw. So the try has finished before the request even fails. When the promise rejects later, the rejection just flows out to whoever called getUser, and the catch is never involved.
async function getUser(id) {
try {
return await fetchUser(id); // now the rejection is thrown HERE
} catch (err) {
return { id, name: "Guest" };
}
}
This is the one place where return await genuinely matters. Outside a try it's redundant, but inside one it's the difference between catching and not catching.
The same root cause shows up in two other forms I see a lot. One is a try/catch wrapped around a setTimeout callback, which runs later on a fresh stack. The other is array.forEach(async item => ...), where forEach ignores the returned promises, so nothing awaits them and their errors go unhandled. For that I use for...of with await, or await Promise.all(items.map(...)).
Interviewers look for: "try/catch only catches what throws while it's on the call stack". Bonus points for the forEach(async ...) variant.
IntermediateHow do you handle errors that nobody catches, globally, in the browser and in Node?
So I treat global handlers as a last-resort safety net for logging and reporting, not as error handling. In the browser there are two events. error fires for uncaught synchronous exceptions, and unhandledrejection fires for promises that reject with no handler.
// Browser
window.addEventListener("error", (event) => {
report(event.error ?? event.message);
});
window.addEventListener("unhandledrejection", (event) => {
report(event.reason);
});
// Node
process.on("unhandledRejection", (reason) => {
logger.error({ reason }, "unhandled rejection");
throw reason; // escalate to uncaughtException below
});
process.on("uncaughtException", (err) => {
logger.fatal(err, "uncaught exception, shutting down");
process.exit(1); // state is unknown, let the process manager restart us
});
The Node part is where people go wrong. After an uncaughtException, the process is in an unknown state: a half-finished transaction, a lock that's still held. So I log it, flush, and exit, and I let PM2, Kubernetes or systemd bring up a fresh process. I never "catch and keep serving". Also, since Node 15, an unhandled rejection crashes the process by default. That's a good thing, because it surfaces bugs early.
In a React app I'd also add an Error Boundary so one broken widget shows a fallback instead of blanking the whole page. Error Boundaries don't catch errors in event handlers or async code, though, which is why the window listeners still matter. In my last project, all of these fed into Sentry with the release version attached.
AdvancedWhat should you not catch? How do you decide where to handle an error?
So my rule is: only catch an error where I can actually do something useful with it, like retry, fall back, add context, or show the user a message. Otherwise I let it propagate. The worst pattern is the silent catch, because it turns a loud, obvious bug into mysterious wrong behaviour three screens later.
// Bad: swallows everything, including my own bugs
try {
const prefs = JSON.parse(localStorage.getItem("prefs"));
applyTheme(prefs.theme);
} catch {}
// Better: handle only the failure I expect, rethrow the rest
let prefs = null;
try {
prefs = JSON.parse(localStorage.getItem("prefs"));
} catch (err) {
if (!(err instanceof SyntaxError)) throw err; // not what I planned for
localStorage.removeItem("prefs"); // corrupted, reset it
}
applyTheme(prefs?.theme ?? "light");
Here, in the bad version, a typo inside applyTheme would be swallowed too, and I'd never know. In the better version the try wraps only the call that can fail in the way I expect, and I rethrow anything else.
I also think in terms of operational errors and programmer errors. Operational errors are things like a network timeout, invalid user input or a file not found. They're expected, and I handle them. Programmer errors are things like undefined is not a function. Those are bugs, and catching them just hides them. They should crash loudly in development and be reported in production.
So in practice I handle errors at the boundaries: the API client, the route handler, the UI layer that decides what the user sees. The code in the middle mostly just lets errors pass through.
Interviewers look for: narrow try blocks, rethrowing what you didn't plan for, and handling errors at boundaries. Seniors often bring up the operational-vs-programmer error split.
24. Modules
BasicNamed exports vs default exports: what's the difference, and which do you prefer?
So a module can have any number of named exports, and they're imported by their exact name in curly braces. It can have at most one default export, and the importer can call that whatever they like.
// format.js
export function formatPrice(n) { /* ... */ }
export function formatDate(d) { /* ... */ }
export default function format(x) { /* ... */ }
// consumer.js
import format, { formatPrice, formatDate as fmtDate } from "./format.js";
import whateverIWant from "./format.js"; // same default, different name
I lean towards named exports in application code, for a few practical reasons. The name is enforced, so Button is called Button everywhere, and grep and rename-refactors actually work. Editors auto-import them reliably. And a typo in a named import fails at link time with a clear error, whereas a default import will happily bind to anything.
I still use default exports where a tool expects them. Next.js pages and layouts need a default export, and so does React.lazy(() => import("./Chart")), which expects the module's default. So in practice my rule is: named by default, and default only when a framework or lazy-loading contract asks for it.
BasicWhat are the key differences between ES Modules and CommonJS?
So ESM is the standard import/export system that browsers and modern Node both support. CommonJS is Node's original require/module.exports system. The big difference is that ESM is static and CommonJS is dynamic, and a lot of other differences follow from that.
// CommonJS: require is just a function call, runs anywhere, synchronously
const fs = require("fs");
if (debug) { const pretty = require("pretty-log"); }
module.exports = { read };
// ESM: imports are declarations, top level only, resolved before any code runs
import fs from "node:fs";
export function read() {}
const pretty = debug ? await import("pretty-log") : null; // dynamic when needed
The differences I usually walk through:
- Static structure. ESM's import graph is known before execution, which is what enables tree shaking and early errors for missing exports.
- Bindings. ESM imports are live bindings to the exporter's variable. CommonJS gives you whatever value was in
module.exportsat the time you required it. - Loading. ESM loads asynchronously and supports top-level
await.requireis synchronous. - Defaults. ESM is always in strict mode,
thisat the top level isundefined, and there's no__dirname. I useimport.meta.dirnameorimport.meta.urlinstead.
In Node, a file is treated as ESM if it's .mjs or if package.json has "type": "module". New projects I start as ESM.
Interviewers look for: "static vs dynamic" as the root difference, with live bindings and tree shaking as consequences. Follow-up: can you require an ES module? In recent Node versions, yes, as long as it has no top-level await.
IntermediateWhat are live bindings? Predict the output for ESM vs CommonJS.
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from "./counter.mjs";
console.log(count); // ?
increment();
console.log(count); // ?
count = 10; // ?
// ---- CommonJS version ----
// counter.cjs
let count = 0;
module.exports = { count, increment() { count++; } };
// main.cjs
const c = require("./counter.cjs");
c.increment();
console.log(c.count); // ?
So with ESM it logs 0, then 1, and then count = 10 throws TypeError: Assignment to constant variable. In CommonJS it logs 0.
The reason is that an ES import isn't a copy. It's a read-only view onto the exporting module's own variable. When increment() changes count inside the counter module, every importer sees the new value. But only the owning module is allowed to write to it, which is why assigning from outside throws.
CommonJS is just objects. At require time, module.exports was { count: 0, ... }, a snapshot of the number. Incrementing the local count variable afterwards doesn't touch that property. To make it work in CommonJS I'd need a getter, like get count() { return count; }.
So in practice live bindings are what let a module export something like a config value that gets set up once during bootstrap, and have everyone see the initialised value.
IntermediateWhen and how do you use dynamic import()?
So import() loads a module on demand and returns a promise for its namespace object. I use it whenever code isn't needed for the first render. Bundlers see it and split that module into a separate chunk, so the initial bundle gets smaller.
// Load a heavy library only when the user actually needs it
exportButton.addEventListener("click", async () => {
exportButton.disabled = true;
const { utils, writeFile } = await import("xlsx"); // ~400 KB, not in main bundle
const sheet = utils.json_to_sheet(rows);
// ...
exportButton.disabled = false;
});
// React route-level splitting
const Settings = React.lazy(() => import("./pages/Settings"));
In my last project the dashboard pulled in a charting library and a spreadsheet exporter, and most users never opened either one. Moving both behind import() cut our main bundle by about a third and noticeably improved load time on mobile.
A few things I keep in mind. The specifier should be a static string, or at least a template literal with a fixed prefix, so the bundler can work out what to split. Fully dynamic paths either break or pull in everything. The first click now involves a network request, so I show a loading state, and sometimes I prefetch on hover. And the module is cached after the first load, so later calls are instant.
Interviewers look for: connecting import() to code splitting and real bundle-size wins, and handling the loading and error states of the lazy chunk.
IntermediateWhat is tree shaking, and what stops it from working?
So tree shaking is the bundler removing exports that nothing imports. It relies on ESM's static structure. Because imports are declarations at the top level, the bundler can see exactly which names are used without running the code.
// Pulls in all of lodash (CommonJS build), about 70 KB
import _ from "lodash";
_.debounce(fn, 300);
// Only debounce and its internals end up in the bundle
import { debounce } from "lodash-es";
The things I've seen break it:
- CommonJS dependencies.
requireis dynamic, so the bundler has to keep everything. - Side effects at module level. If a file does
window.foo = ...or registers something on import, the bundler can't drop it safely. Libraries mark themselves safe with"sideEffects": falseinpackage.json. I list CSS imports there as side effects, otherwise the styles vanish. - Namespace or object usage the bundler can't follow, like
import * as utilsand thenutils[name]. - Big barrel files (
index.jsthat re-exports everything). They usually shake OK in production, but they slow down dev builds and tests, and any side effect in one of them drags the whole lot in.
So in practice I check with a bundle analyzer rather than assuming. Twice now I've found a single default import quietly pulling in an entire icon library.
AdvancedWhat happens with circular dependencies in ESM vs CommonJS?
So both systems allow cycles, but they fail in different ways. CommonJS gives you a half-filled exports object, so you silently get undefined. ESM gives you a TDZ ReferenceError if you touch a let, const or class binding before its module has run.
// a.mjs (entry point)
import { b } from "./b.mjs";
export const a = "A";
console.log("a sees", b);
// b.mjs
import { a } from "./a.mjs";
export const b = "B";
console.log("b sees", a); // ReferenceError: Cannot access 'a' before initialization
What happens is that the loader links the whole graph first, then evaluates it depth-first. a imports b, so b runs first. At that moment a's binding exists but hasn't been initialised yet, so reading it throws. Function declarations are an exception: they're initialised during linking. So if a.mjs exported function getA(), b could call it fine.
In CommonJS the same shape logs b sees undefined, because require("./a") returns a's exports object while it's still empty. That's arguably worse, because nothing crashes.
How I fix it in practice: usually a cycle means two modules share something that belongs in a third module, so I extract that. Otherwise I only access the import lazily, inside a function that runs later, not at module top level. I've also used madge --circular in CI to catch new cycles before they cause these weird initialisation-order bugs.
Interviewers look for: the link-then-evaluate model, the TDZ error vs the silent undefined, and the refactor (extract the shared piece) rather than a hack.
AdvancedIf two files import the same module, does it run twice? What does that imply?
So no. A module is evaluated once, the first time it's imported, and then it's cached by its resolved URL or path. Every importer after that gets the same instance. Modules are effectively singletons, and that has real consequences both ways.
// store.js
console.log("store module evaluated");
export const cache = new Map();
// a.js
import { cache } from "./store.js";
cache.set("user", { id: 1 });
// b.js
import { cache } from "./store.js";
console.log(cache.get("user")); // { id: 1 }: same Map
// main.js
import "./a.js";
import "./b.js";
// "store module evaluated" logs only once
The upside is that I get cheap shared state: a single API client, a config object, a logger. No DI container needed.
The downsides I've run into:
- Tests leak state between each other through that module-level Map. So I either export a
reset()or use the test runner's module reset. - In server-rendered apps, module state is shared across all requests. Putting per-user data in a module-level variable is a real data-leak bug.
- If the same package ends up in node_modules twice, say two different versions, I get two "singletons", and
instanceofchecks between them fail. That's the classic "two copies of React" invalid-hook-call error.
The cache is keyed by the resolved specifier. So ./store.js and ./store.js?v=2 in the browser are two separate module instances.
25. Memory & Garbage Collection
BasicHow does JavaScript decide what memory to free?
So it's based on reachability, not on whether I'm "done" with something. The garbage collector starts from a set of roots: the global object, the current call stack, and active closures and handles. Everything it can reach by following references from there is alive. Everything else is garbage, even if it still references other garbage.
let user = { name: "Ada" };
let admin = user; // two references to the same object
user = null; // still reachable through admin, so kept
admin = null; // unreachable now, so eligible for GC
function makePair() {
const a = {}, b = {};
a.other = b;
b.other = a; // a cycle
}
makePair(); // after return, both unreachable: collected
That cycle at the end is why reachability matters. Old reference-counting collectors, like the one in old IE for DOM objects, would leak there, because each object still has a count of 1. Mark-and-sweep doesn't care. It marks everything reachable from the roots, then sweeps the rest, so an isolated cycle just disappears.
The practical takeaway is that I can't free memory directly in JS. I can only stop referencing it. So a "leak" in JS always means some reachable thing, like a global, a cache, a listener or a timer, is still pointing at data I thought was gone.
BasicCan you have memory leaks in a garbage-collected language? Give examples.
So yes, definitely. The GC only frees what's unreachable, so a leak is anything I accidentally keep reachable. In a long-lived single-page app that adds up, because the page never reloads to wipe the slate clean.
The ones I actually see in real code:
// 1. Timers that are never cleared: the callback keeps its closure alive
const id = setInterval(() => updateChart(bigDataset), 1000);
// on unmount / navigation: clearInterval(id);
// 2. Listeners on long-lived targets (window, document, a global bus)
window.addEventListener("resize", this.onResize);
// missing removeEventListener, so this component instance lives forever
// 3. Unbounded caches
const cache = {};
function getReport(id) {
return cache[id] ??= buildReport(id); // grows forever
}
// 4. Accidental globals (sloppy mode)
function track() { lastEvent = new Event("x"); } // becomes window.lastEvent
The fifth one is detached DOM: a node removed from the page that JS still references, so the whole subtree stays in memory.
In a React app I worked on, a dashboard page added a resize listener and started a polling interval in useEffect with no cleanup. Every time someone navigated away and back, another copy stuck around. After an hour the tab was using over a gigabyte and felt sluggish. The fix was just returning a cleanup function from the effect. So in practice, anything I start in a lifecycle, like a subscription, a timer or a listener, gets a matching teardown in the same place.
Interviewers look for: concrete leak sources tied to SPA lifecycles (mount and unmount), not just "globals". Strong candidates mention unbounded caches and how they'd bound them (LRU, WeakMap).
IntermediateThis cleanup doesn't remove the listener. Why, and how would you fix it?
class Tooltip {
mount() {
window.addEventListener("scroll", () => this.reposition());
}
unmount() {
window.removeEventListener("scroll", () => this.reposition());
}
}
So removeEventListener matches by function reference, and those are two different arrow functions that just happen to look the same. The remove silently does nothing. window keeps the first arrow, the arrow closes over this, and so every Tooltip instance ever mounted stays in memory and keeps repositioning on scroll.
The fix is to keep a reference to the exact same function. My preferred modern approach is AbortController, because one abort() can tear down many listeners at once.
class Tooltip {
#controller = null;
mount() {
this.#controller = new AbortController();
const { signal } = this.#controller;
window.addEventListener("scroll", () => this.reposition(), { signal });
window.addEventListener("resize", () => this.reposition(), { signal });
}
unmount() {
this.#controller?.abort(); // removes both listeners
}
}
The other valid fix is storing the handler once, like this.onScroll = () => this.reposition(), and passing that same reference to both calls. The same trap exists with .bind(this), because every bind call returns a new function.
So in practice, if I see removeEventListener with an inline function, I know it's a bug straight away.
IntermediateHow can a closure keep more memory alive than you'd expect?
So a closure keeps its outer scope's variables alive for as long as the closure itself is reachable. The subtle part is how V8 implements it. All the closures created in one scope share a single context object, and that object holds every variable that any of those closures references. So one closure can keep data alive that it never uses.
function setupWidget() {
const hugeRows = new Array(1_000_000).fill({ price: 1 });
const logCount = () => console.log(hugeRows.length); // uses hugeRows
logCount();
return () => console.log("tick"); // doesn't use hugeRows...
}
const tick = setupWidget();
setInterval(tick, 1000);
// ...but hugeRows is still retained: it lives in the shared context
// because logCount referenced it, and tick holds that same context.
Here I'd expect hugeRows to be freed once setupWidget returns, since the function I kept never touches it. But because a sibling closure captured it, it went into the shared context, and tick keeps that context reachable. If no inner function referenced hugeRows at all, V8 would keep it on the stack and it would be freed as normal.
The fixes are simple once you know about it. Pull the big data into a separate function so it doesn't share a scope with the long-lived closure. Or set it to hugeRows = null when I'm done with it. Or only capture the small piece I need, like const count = hugeRows.length.
Interviewers look for: knowing that closures capture scopes (variables), not values, and that long-lived callbacks like timers, listeners and caches are what turn this into a leak.
IntermediateWhen would you use WeakMap, WeakSet or WeakRef?
So a WeakMap holds its keys weakly. If the key object becomes unreachable everywhere else, the entry disappears along with it. I use it whenever I want to attach data to an object I don't own, without becoming the reason that object stays in memory.
// Per-element metadata that dies with the element
const tooltipState = new WeakMap();
function attachTooltip(el) {
tooltipState.set(el, { shownAt: null, text: el.title });
}
// When el is removed from the DOM and dropped, its entry is collectable too.
// With a regular Map, the Map itself would keep every element alive forever.
// Memoizing by object identity without leaking
const cache = new WeakMap();
function expensiveLayout(node) {
if (!cache.has(node)) cache.set(node, computeLayout(node));
return cache.get(node);
}
The trade-offs: keys must be objects, or non-registered symbols. And you can't iterate a WeakMap or get its size, because its contents depend on when GC happens to run. WeakSet is the same idea for membership, for example marking objects I've already processed.
WeakRef and FinalizationRegistry are much rarer for me. A WeakRef lets me hold an object without keeping it alive, and deref() returns it or undefined. I'd only reach for it for something like a cache of large decoded images. GC timing isn't guaranteed, and finalizers might never run, so I never put correctness logic in them.
IntermediateWhat is a detached DOM node, and how does it leak?
So a detached DOM node is an element that's been removed from the document but is still referenced from JavaScript. Because it's reachable, the GC can't free it. And since a node references its parent and children, keeping one node can keep an entire removed subtree alive, along with any listeners and data hanging off it.
const rowCache = [];
function renderTable(rows) {
const tbody = document.querySelector("tbody");
tbody.innerHTML = ""; // old rows removed from the page...
for (const row of rows) {
const tr = document.createElement("tr");
tr.textContent = row.name;
tbody.append(tr);
rowCache.push(tr); // ...but every one is still in here
}
}
// Re-render 50 times with 1,000 rows each: 50,000 detached <tr>s in memory.
Here innerHTML = "" takes the rows off the page, but rowCache still points to every tr ever created. The fix is to clear the cache when I clear the table, or to key the metadata in a WeakMap so it goes away with the node.
To find these, I take a heap snapshot in the Memory panel and type "Detached" into the class filter. That lists things like Detached HTMLTableRowElement. Then I click one and read its Retainers path, which tells me exactly which variable is holding on to it. In this case it would point straight at rowCache.
AdvancedWalk me through how you'd find a memory leak using DevTools.
So first I confirm it's actually a leak and not just high but stable memory. Then I use heap snapshots to work out what is growing and who is retaining it. My process looks like this.
First, I reproduce it with a repeatable action, something like "open the modal and close it" or "navigate to the dashboard and back". Then I watch the Performance monitor, or take a Performance recording with the Memory checkbox on. If the JS heap sawtooths back to the same baseline after GC, that's fine. If the baseline keeps climbing after every cycle, that's a leak.
Then I use the three-snapshot technique in the Memory panel:
1. Load the page, do the action once (warm up caches), click the trash icon (force GC)
2. Take Snapshot 1
3. Do the action N times (say 10), force GC, take Snapshot 2
4. Do the action N more times, force GC, take Snapshot 3
5. Select Snapshot 3, switch to "Comparison" against Snapshot 2
and sort by "# Delta" / "Size Delta"
Now I look for constructors whose count grows by roughly N, or a multiple of N, like 10 more ModalController, 10 more Detached HTMLDivElement, or 10 more closures. Then I click an instance and read the Retainers panel bottom-up. It shows the path from a GC root down to that object, something like Window → eventBus → listeners → Array → (closure) → ModalController. That path is basically the bug report.
In my last project, that path led to a global event bus where each modal subscribed on open and never unsubscribed. For leaks that are allocation-driven rather than retention-driven, I switch to "Allocations on timeline", which shows the stack trace where each surviving object was created. And in Node I do the same thing with node --inspect and Chrome DevTools, or with v8.writeHeapSnapshot().
Interviewers look for: a method (baseline, repeat, compare, retainers) rather than "I'd open the Memory tab". Forcing GC before each snapshot, and reading the retainer path, are the details that show real experience.
AdvancedHow does V8's garbage collector work at a high level, and how can GC hurt performance?
So V8's GC is generational, based on the observation that most objects die young. New objects are allocated in a small young generation. It's collected often and cheaply by a "scavenger", which copies the few survivors out and throws the rest of the space away in one go. Objects that survive a couple of scavenges get promoted to the old generation. That's collected much less often by a mark-sweep-compact collector.
To keep pauses short, most of the old-generation marking happens incrementally and concurrently on background threads, with only short stop-the-world phases on the main thread. Still, GC isn't free, and heavy allocation in a hot path shows up as jank.
// Allocates a new array + object every frame: lots of short-lived garbage
function animate() {
const pos = particles.map(p => ({ x: p.x + p.vx, y: p.y + p.vy }));
draw(pos);
requestAnimationFrame(animate);
}
// Mutates in place: near-zero allocation per frame
function animate() {
for (const p of particles) { p.x += p.vx; p.y += p.vy; }
draw(particles);
requestAnimationFrame(animate);
}
Here, with 10,000 particles at 60 fps, the first version creates 600,000 objects a second. That keeps the scavenger busy, and in a Performance recording you see periodic "Minor GC" blocks eating into frames. The second version barely allocates.
So in practice I write normal, readable, immutable code almost everywhere, because young-gen GC is really cheap. I only switch to in-place mutation or object pools in genuinely hot paths, like animation loops, games, parsers or big data transforms, and only after a profile shows GC time is the problem.
26. Functional Patterns
BasicWhat's a pure function? Are these pure?
const withTax = (price) => price * (1 + TAX_RATE); // A
const addItem = (cart, item) => { cart.push(item); return cart; }; // B
const stamp = (msg) => `${Date.now()}: ${msg}`; // C
const total = (items) => items.reduce((s, i) => s + i.price, 0); // D
So a pure function has two properties. It always returns the same output for the same input, and it has no side effects: it doesn't mutate its arguments, touch the DOM, write to storage or make network calls.
Going through them:
- A is only pure if
TAX_RATEis a true constant. If it's aletthat can change, the same price can give different results, so I'd pass the rate in as a parameter. - B is impure. It mutates the
cartit was given, so the caller's array changes under them. The pure version is(cart, item) => [...cart, item]. - C is impure. It depends on the clock, so every call differs. I'd inject the time instead:
(msg, now) => .... - D is pure. It only reads its input and returns a new value.
The reason I care is practical. Pure functions are trivial to unit test, with no mocks. They're safe to memoize, and they're what React expects from render functions and reducers.
So in practice I try to keep the core logic pure and push the impure stuff, like fetching, time, randomness and the DOM, out to the edges, where it's easy to see and mock.
BasicWhat's a higher-order function? Give examples you actually use.
So a higher-order function is one that takes a function as an argument, returns a function, or both. They work because functions in JS are just values. I use them constantly without thinking of them by that name.
// Takes a function: map, filter, reduce, sort, addEventListener
const activeEmails = users
.filter(u => u.active)
.map(u => u.email);
// Returns a function: a small factory I actually use
const byKey = (key) => (a, b) => a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0;
products.toSorted(byKey("price"));
products.toSorted(byKey("name"));
// Both: wrap behaviour around any function
function withLogging(fn) {
return function (...args) {
console.time(fn.name);
try { return fn.apply(this, args); }
finally { console.timeEnd(fn.name); }
};
}
const timedParse = withLogging(parseReport);
The byKey one is a nice example. Instead of writing a new comparator for every field, I write one function that builds comparators.
withLogging is the decorator pattern. debounce, throttle, memoize, Express middleware and React's old higher-order components are all the same shape: take a function, return an enhanced one. One detail I always include is using function and fn.apply(this, args) in the wrapper, so it works on methods too and doesn't lose this.
Interviewers look for: "functions are first-class values", real examples beyond map, and often a follow-up to write debounce or once on the spot.
IntermediateWhy does immutability matter, and what's the bug in this state update?
function updateCity(state, city) {
const next = { ...state };
next.user.address.city = city;
return next;
}
So the bug is that spread is a shallow copy. next is a new top-level object, but next.user is the exact same object as state.user. Writing to next.user.address.city mutates the original state too. In React or Redux, a memoized component that compares prev.user === next.user sees the same reference and skips re-rendering, so the UI shows the old city. And the "previous" state I might use for undo has been changed as well.
The fix is to copy along the path I'm changing, and only that path:
function updateCity(state, city) {
return {
...state,
user: {
...state.user,
address: { ...state.user.address, city },
},
};
}
Everything I didn't touch keeps its old reference. That's actually the point, because it lets memoized children skip work cheaply with ===.
Once nesting gets deep, I use Immer, where I write draft.user.address.city = city and it produces this structure for me. Redux Toolkit does that under the hood. I'd also mention that Object.freeze is shallow too. It's useful in development to catch mutations, but it doesn't protect nested objects.
Interviewers look for: "spread is shallow", and the reason immutability matters in React: cheap change detection by reference. Deep-cloning everything is the wrong fix, because it breaks memoization.
IntermediateImplement curry. How is it different from partial application?
So currying turns a function of N arguments into a chain of functions that you can call with one, or some, arguments at a time, until all N have been supplied. Partial application is simpler: I fix some arguments now and get back a function that takes the rest in one go. bind is built-in partial application.
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return (...more) => curried.apply(this, [...args, ...more]);
};
}
const volume = curry((l, w, h) => l * w * h);
volume(2)(3)(4); // 24
volume(2, 3)(4); // 24
volume(2)(3, 4); // 24
// Partial application: fix some args, call once with the rest
const logError = console.log.bind(console, "[ERROR]");
logError("disk full"); // [ERROR] disk full
Here the curried function collects arguments across calls. Once it has at least fn.length of them, it calls the original.
The gotcha is that fn.length doesn't count parameters with defaults or rest parameters, so curry((a, b = 1) => ...) fires after just one argument. For those functions I'd pass the arity explicitly, as curry(fn, 2).
Where it's actually useful for me is building small configured helpers. For example, const hasRole = curry((role, user) => user.roles.includes(role)), then users.filter(hasRole("admin")). The data goes last so it slots straight into map and filter. In most app code, though, a plain arrow like u => hasRole("admin", u) is just as clear.
IntermediateWhat are compose and pipe? Write them and show where you'd use one.
So both combine small functions into one, where each function's output feeds the next one's input. pipe runs left to right, which reads like a list of steps. compose runs right to left, like maths notation f(g(x)). I nearly always use pipe, because it reads in execution order.
const pipe = (...fns) => (input) => fns.reduce((acc, fn) => fn(acc), input);
const compose = (...fns) => (input) => fns.reduceRight((acc, fn) => fn(acc), input);
// Normalising a search query before hitting the API
const normaliseQuery = pipe(
(s) => s.trim(),
(s) => s.toLowerCase(),
(s) => s.normalize("NFD").replace(/\p{Diacritic}/gu, ""),
(s) => s.replace(/\s+/g, " "),
);
normaliseQuery(" Café CRÈME "); // "cafe creme"
What I like here is that each step is a tiny, testable function. Adding or removing a step is one line, and the name normaliseQuery documents the whole thing.
The same idea shows up with middleware and with Redux's compose for store enhancers. The trade-off is debugging: a stack trace inside a long pipe is less obvious. So I don't pipe for the sake of it. If it's two steps, b(a(x)) is fine. For async steps I'd write an async version that does await fn(acc) in a loop.
Interviewers look for: the reduce one-liner, knowing the order difference, and a sensible opinion on when it helps readability versus when it hurts.
IntermediateImplement memoize. What are the pitfalls?
So memoization caches a function's results keyed by its arguments, so repeat calls with the same input return straight from the cache. It only makes sense for pure functions. If the output depends on anything other than the arguments, the cache will serve stale answers.
function memoize(fn, { key = (...args) => JSON.stringify(args), max = 500 } = {}) {
const cache = new Map();
return function (...args) {
const k = key(...args);
if (cache.has(k)) return cache.get(k);
const result = fn.apply(this, args);
cache.set(k, result);
if (cache.size > max) cache.delete(cache.keys().next().value); // drop oldest
return result;
};
}
const slowSquare = (n) => { for (let i = 0; i < 1e8; i++); return n * n; };
const fastSquare = memoize(slowSquare);
fastSquare(9); // slow the first time
fastSquare(9); // instant
The pitfalls I watch for:
- Key generation.
JSON.stringifyis fine for primitives, but it's costly for large objects and it treats{a:1,b:2}and{b:2,a:1}as different keys. For single-object arguments I use aWeakMapkeyed by the object itself. - Unbounded growth. Without the
maxit's a memory leak, which is why I evict. Because aMapkeeps insertion order, deleting the first key is a cheap FIFO. - Caching promises. If I memoize an async function, a rejected promise is cached forever, so I delete the entry on failure.
- It isn't free. For cheap functions the hashing costs more than the work itself.
This is the same trade-off as React's useMemo. I measure first, then memoize the genuinely expensive stuff.
AdvancedPredict the output: ["1", "2", "3"].map(parseInt)
So it's [1, NaN, NaN], and it's a nice example of why point-free style can bite you.
["1", "2", "3"].map(parseInt);
// map calls the callback with (element, index, array), so this is really:
parseInt("1", 0); // radix 0 is treated as "auto", so base 10: 1
parseInt("2", 1); // radix 1 is invalid: NaN
parseInt("3", 2); // "3" isn't a valid binary digit: NaN
// Fixes
["1", "2", "3"].map(Number); // [1, 2, 3]
["1", "2", "3"].map((s) => parseInt(s, 10)); // [1, 2, 3]
What happens is that map doesn't pass just the element. It passes the element, the index and the array. parseInt happens to accept a second parameter, the radix, so the index is silently used as the base.
The general lesson I take from it is that passing a function reference directly, like .map(fn), is only safe when fn is designed for that signature. The same trap hits with things like .map(Math.max), or my own utilities that grow an optional second parameter later and suddenly start receiving the index. So when I'm passing a function I don't own, I wrap it in an arrow that passes exactly the arguments I mean.
Interviewers look for: knowing map's (value, index, array) signature and the radix rule. Follow-up: ["10", "10", "10"].map(parseInt) gives [10, NaN, 2].
AdvancedWhen does the immutable, functional style hurt? Can you show a performance trap?
So the classic trap is spreading an accumulator inside reduce. It looks clean and immutable, but every iteration copies everything built so far, so the whole thing becomes O(n²).
// Looks nice, but O(n^2): copies 1, 2, 3 ... n keys
const byId = users.reduce((acc, u) => ({ ...acc, [u.id]: u }), {});
// O(n), and still pure from the caller's point of view
const byId2 = Object.fromEntries(users.map((u) => [u.id, u]));
// Or mutate a local accumulator: nobody else can see it
const byId3 = {};
for (const u of users) byId3[u.id] = u;
// Or just use a Map
const byId4 = new Map(users.map((u) => [u.id, u]));
With 10 users you'd never notice. In one project we had a normaliser like this running on about 20,000 records from an import, and it took several seconds and froze the tab. Switching to Object.fromEntries took it down to a few milliseconds.
The principle I go by is that immutability matters at the boundaries, meaning what other code can observe, like state, props and function arguments. Mutating a local variable that I created inside the function and haven't shared yet is completely fine. The function is still pure from the outside.
Other places where FP style costs me: long .filter().map().filter() chains over huge arrays create intermediate arrays at each step, where a single loop or lazy iterator helpers would avoid that. And deep currying and point-free code can make stack traces and onboarding harder. So in practice I go functional by default for clarity, and pragmatic in hot paths.
27. Modern JS Features
BasicWhich ES2020+ features do you actually use day to day?
So the honest answer is that a handful of them are in basically every file I write now, and a few newer ones have replaced little helpers I used to write by hand.
// Optional chaining + nullish coalescing: safe access with a real default
const city = user?.address?.city ?? "Unknown";
onClose?.(); // call only if the prop was passed
// Logical assignment
options.retries ??= 3;
// at(): negative indexing without length maths
const last = messages.at(-1); // instead of messages[messages.length - 1]
// Non-mutating array methods (great for React state)
const sorted = products.toSorted((a, b) => a.price - b.price);
// Grouping
const byStatus = Object.groupBy(orders, (o) => o.status);
// Real deep copy
const draft = structuredClone(formState);
// Safer own-property check
Object.hasOwn(config, "timeout");
// Private class fields, top-level await in modules, Promise.allSettled
The ones with the biggest impact on real bugs are ??, because it stopped a whole class of "0 got replaced with the default" bugs, and toSorted/with, because they stopped accidental state mutation.
Object.groupBy and structuredClone replaced two lodash functions we were importing just for that. I check Baseline status on MDN before using anything very new. For older browser targets I rely on the build's polyfills, through core-js, rather than avoiding the features.
Interviewers look for: genuine usage with a reason ("this removed a bug class", "this replaced a dependency"), not a memorised list of every proposal.
BasicWhat's the difference between ?? and ||? Predict the output.
const settings = { volume: 0, title: "", theme: null };
settings.volume || 50; // ?
settings.volume ?? 50; // ?
settings.title || "Untitled"; // ?
settings.title ?? "Untitled"; // ?
settings.theme ?? "light"; // ?
settings.missing?.length ?? 0; // ?
So the answers are 50, then 0, then "Untitled", then "", then "light", then 0.
The difference is what counts as "missing". || falls back on any falsy value, which includes 0, "", false and NaN. ?? only falls back on null or undefined. So with ||, a user who deliberately set the volume to 0 gets bumped back up to 50. That's a real bug I've seen in a settings screen.
On the last line, optional chaining short-circuits. settings.missing is undefined, so ?.length returns undefined without throwing, and ?? supplies the 0.
Two small gotchas. You can't mix ?? with || or && without parentheses, because a || b ?? c is a SyntaxError. And ?. only guards the thing directly in front of it, so user?.profile.name still throws if profile is undefined. So in practice I use ?? for defaults, and || only when I really do want to treat empty strings and zero as missing.
IntermediateWhy were toSorted, toReversed, toSpliced and with added? What bug do they prevent?
So the originals, sort, reverse and splice, mutate the array in place. That's a trap whenever the array is shared, like React state, props, or data from a cache. ES2023 added copying versions that return a new array and leave the original alone.
function ProductList({ products }) {
// Bug: sorts the parent's prop array in place
const sorted = products.sort((a, b) => a.price - b.price);
// ...
}
// Fix: returns a new array, props untouched
const sorted = products.toSorted((a, b) => a.price - b.price);
// The other copying methods
const newest = items.toReversed();
const withoutThird = items.toSpliced(2, 1);
const renamed = items.with(0, { ...items[0], name: "New" }); // replace index 0
// And the default-sort gotcha still applies
[10, 1, 2].toSorted(); // [1, 10, 2]: compares as strings!
In that component, products.sort() quietly reorders the parent's array. The parent's state now holds a mutated array with the same reference, so other components render in the wrong order, and React may not re-render when you expect. It's a nasty bug because the code "works" in the component that caused it.
Before these existed I'd write [...products].sort(...), which is fine, but people forgot the spread. with is especially handy for updating one index immutably, replacing a map with an index check. One detail: these always return a plain Array, even when called on a subclass.
Interviewers look for: naming which methods mutate (sort, reverse, splice, push, fill...), the React reference-equality consequence, and the default string sort gotcha.
IntermediatestructuredClone vs JSON.parse(JSON.stringify()): what's the difference?
So both make a deep copy, but the JSON trick only understands what JSON can represent. It silently loses or mangles everything else. structuredClone uses the same algorithm as postMessage, so it handles a lot more types and circular references. When it can't clone something, it throws instead of quietly corrupting it.
const original = {
createdAt: new Date("2024-01-01"),
tags: new Set(["a", "b"]),
meta: new Map([["k", 1]]),
score: undefined,
big: 10n,
};
original.self = original;
JSON.parse(JSON.stringify(original));
// TypeError: Converting circular structure to JSON
// (and even without the cycle: Date becomes a string, Set/Map become {},
// score disappears, and BigInt throws)
const copy = structuredClone(original);
copy.createdAt instanceof Date; // true
copy.tags.has("a"); // true
copy.self === copy; // true: cycle preserved
copy.big; // 10n
The limits of structuredClone I keep in mind: it throws a DataCloneError on functions and DOM nodes. Class instances come back as plain objects, so the prototype and methods are lost. Getters get evaluated into plain values. And symbol-keyed properties are dropped.
So in practice I use structuredClone for "give me an independent copy of this data", like a form draft or a snapshot for undo. I'd only use the JSON approach when I actually want a JSON-shaped result. And I remind myself that most of the time in React I don't want a deep clone at all, just a shallow copy of the path I'm changing.
IntermediateHow does Object.groupBy work, and why isn't it array.groupBy?
So Object.groupBy(iterable, callback) calls the callback for each item and collects items into arrays under whatever key it returns. It replaced the reduce boilerplate, or lodash's groupBy, that everyone used to write.
const orders = [
{ id: 1, status: "paid", total: 40 },
{ id: 2, status: "pending", total: 15 },
{ id: 3, status: "paid", total: 99 },
];
const byStatus = Object.groupBy(orders, (o) => o.status);
// { paid: [{id:1..}, {id:3..}], pending: [{id:2..}] }
byStatus.paid.length; // 2
byStatus.refunded; // undefined: missing groups aren't created
// Group into buckets, not just by a field
const bySize = Object.groupBy(orders, (o) => (o.total > 50 ? "large" : "small"));
// Map.groupBy when the key should be an object, or you want Map semantics
const byCustomer = Map.groupBy(orders, (o) => customersById.get(o.id));
Two details worth knowing. The result is a null-prototype object, so it's safe even if a key is "constructor" or "__proto__". But it also means methods like byStatus.hasOwnProperty don't exist, so I use Object.hasOwn. It also accepts any iterable, not just arrays.
As for why it's a static method: it was originally proposed as Array.prototype.group, but adding methods to Array.prototype kept breaking existing websites whose libraries had patched the same name. So it was moved to Object and Map, which also neatly gives you a choice of output type.
IntermediateHow do private class fields (#x) differ from the _x convention or closures?
So # fields are genuinely private, enforced by the language. Code outside the class body can't read them, can't write them, and can't even detect them by name. An underscore is just a naming convention that anyone can ignore. Closures gave real privacy before, but only by creating the methods per instance.
class BankAccount {
#balance = 0;
static #instances = 0;
constructor() { BankAccount.#instances++; }
deposit(amount) {
if (amount <= 0) throw new RangeError("Deposit must be positive");
this.#balance += amount;
}
get balance() { return this.#balance; }
#audit() { /* private method */ }
static isAccount(obj) { return #balance in obj; } // brand check
}
const acct = new BankAccount();
acct.deposit(100);
acct.balance; // 100
acct.#balance; // SyntaxError (outside the class body)
Object.keys(acct); // []
JSON.stringify(acct); // "{}"
BankAccount.isAccount({ balance: 5 }); // false
What I like here is that the invariant, "balance only changes through deposit", is actually enforced. And #balance in obj is a reliable way to check that something really is one of my instances, which duck typing can't give me.
The gotchas I know about: private fields don't work through a Proxy, because accessing #balance on the proxy throws a TypeError, and that has bitten people using Vue's reactivity or MobX with classes. Subclasses can't access a parent's # fields either. There's no "protected". And they're not visible to JSON.stringify or spread, so I add an explicit toJSON if I need serialisation.
AdvancedWhat does top-level await do to the module graph? When would you use it?
So top-level await lets an ES module await outside any function. The important part is its effect on the graph. A module that uses it doesn't finish evaluating until the awaited promise settles, and any module that imports it, directly or indirectly, waits too. Sibling modules that don't depend on it can still evaluate in the meantime.
// config.js
const res = await fetch("/config.json");
if (!res.ok) throw new Error(`Config failed: ${res.status}`);
export const config = await res.json();
// api.js
import { config } from "./config.js"; // evaluation pauses until config is ready
export const api = createClient(config.apiUrl);
// Conditional / fallback loading
const { default: db } = await import(
process.env.NODE_ENV === "test" ? "./db.mock.js" : "./db.js"
);
What this buys me is that importers get a fully initialised config. No init() function, no "is it ready yet" promise passed around everywhere.
The trade-offs are why I use it sparingly. It serialises startup: a slow fetch in a module near the root of the graph delays everything that depends on it, and if that promise never settles, those modules never run at all. If it rejects, the importing module fails to load. It only works in ES modules, not classic scripts or CommonJS. And in Node, a module with top-level await can't be loaded with require().
So in practice I use it at the edges, like a CLI entry point, a Node server bootstrap, config loading or a test setup file. I avoid it in library code and deep inside a front-end bundle.
Interviewers look for: the "it blocks dependents, not siblings" point, and awareness of the startup-latency and failure-propagation trade-off.
AdvancedWhat are Array.fromAsync and Promise.withResolvers, and how do they compare to what we had before?
So Array.fromAsync is the async version of Array.from. It consumes an async iterable, or an iterable of promises, awaits each item in turn, and returns a promise for the array. Promise.withResolvers gives me a promise together with its resolve and reject functions, without the awkward "hoist them out of the executor" pattern.
// Collect every item from a paginated async generator
const allOrders = await Array.fromAsync(fetchAllOrders());
// Before: a manual loop
const collected = [];
for await (const o of fetchAllOrders()) collected.push(o);
// Promise.withResolvers: turning an event into a promise
function waitForMessage(ws, type) {
const { promise, resolve, reject } = Promise.withResolvers();
const onMsg = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === type) { ws.removeEventListener("message", onMsg); resolve(msg); }
};
ws.addEventListener("message", onMsg);
ws.addEventListener("close", () => reject(new Error("socket closed")), { once: true });
return promise;
}
// Before: the let-and-assign dance
let res, rej;
const p = new Promise((a, b) => { res = a; rej = b; });
The subtle point about fromAsync vs Promise.all is that fromAsync is sequential. It awaits each value before pulling the next. For an async generator that's exactly what I want, because the pages have to be fetched in order anyway. But if I have an array of independent URLs, Array.fromAsync(urls, fetchJson) fetches them one at a time, while Promise.all(urls.map(fetchJson)) runs them in parallel. Unlike Promise.all, it also doesn't reject early.
withResolvers I mostly use when wrapping callback- or event-based APIs, and in tests where something outside controls when a promise settles. Both are well supported in current browsers and Node now.
28. The JavaScript Lifecycle
BasicWalk me through what happens from a <script> tag to the first line of code executing.
So I think of it as a pipeline: load, parse, compile, then a creation phase and an execution phase for the global code. After that, the call stack and event loop take over for the rest of the page's life.
<script src="app.js"></script>
1. Load : HTML parser hits the tag, pauses, fetches app.js over the network
2. Parse : the engine turns the source text into an AST, so a SyntaxError here
means NONE of the file runs
3. Compile : AST becomes bytecode (V8's Ignition); hot code is optimised later
4. Creation : global execution context is set up: var gets undefined,
function declarations are fully hoisted, let/const/class go into
the TDZ, and `this` is bound
5. Execute : code runs top to bottom on the call stack
6. Async : timers, fetch and listeners are handed to Web APIs; the event loop
later pushes their callbacks back onto the stack
7. GC : unreachable memory is freed in the background
Walking through it: a plain script blocks the HTML parser while it downloads and runs, because it might call document.write or read the DOM built so far. Once the engine has the text, it parses it. Every function call later repeats the creation-then-execution steps with its own execution context.
When the global code finishes, the call stack empties. From then on, everything that runs is a callback the event loop picked up: all queued microtasks first, then the next macrotask, with rendering opportunities in between.
So if someone asks "why is my variable undefined instead of throwing?" or "why didn't any of my file run?", I can place the answer on this pipeline: creation-phase hoisting in the first case, a parse-time SyntaxError in the second.
Interviewers look for: the stages in the right order, and connecting them to observable behaviour: parse errors kill the whole file, hoisting comes from the creation phase, async callbacks only run when the stack is empty.
BasicWhat's the difference between a normal script, defer, async and type="module"?
So the difference is two things: whether the download blocks HTML parsing, and when the script executes relative to parsing and to the other scripts.
<script src="a.js"></script>
<!-- blocks parsing to download AND run. Old-school default. -->
<script defer src="b.js"></script>
<!-- downloads in parallel, runs after the HTML is parsed,
in document order, before DOMContentLoaded -->
<script async src="c.js"></script>
<!-- downloads in parallel, runs the moment it arrives (pausing parsing),
in any order, may be before or after DOMContentLoaded -->
<script type="module" src="d.js"></script>
<!-- deferred by default, plus it fetches its whole import graph;
add async to run it as soon as that graph is ready -->
How I choose in practice: my own app code that needs the DOM and depends on other scripts gets defer, or is a module, which is deferred anyway. Independent third-party things like analytics or an ad tag get async, because they don't care about order and I don't want them delaying anything. And I basically never ship a plain blocking script in the <head>. That's the classic render-blocking resource Lighthouse complains about.
A few gotchas. defer and async are ignored on inline classic scripts, because there's nothing to download. With async, two scripts that depend on each other can race, so it's only safe for truly independent ones. And a bare defer script at the bottom of <body> gains almost nothing over a plain one there. The real win is putting it in the <head>, so the download starts early.
IntermediatePredict the output. One of these scripts has a syntax error.
<script>
console.log("first");
</script>
<script>
console.log("second");
const total = ; // syntax error
</script>
<script>
console.log("third");
missingFunction(); // runtime error
console.log("fourth");
</script>
<script>
console.log("fifth");
</script>
So the console shows "first", then a SyntaxError, then "third", then a ReferenceError for missingFunction, and then "fifth". "second" and "fourth" never print.
The reason is the difference between the parse and execute stages. The second script fails to parse. A SyntaxError is found before any of that script runs, so even the console.log on the line above the error never executes. The whole script is rejected.
The third script parses fine and starts running, so "third" logs. Then the ReferenceError is thrown at runtime and stops the rest of that script, so "fourth" is skipped.
Each <script> element is parsed and run on its own, so an error in one doesn't stop the next. That's why "fifth" still runs.
This is also why one bad line in a bundle can take down an entire app: the whole bundle is one script. And it's why I'm careful with third-party inline snippets. They're isolated as separate scripts, but if my code depends on a global they were supposed to define, it'll hit a ReferenceError later.
Interviewers look for: "parse errors kill the whole script, runtime errors stop it at that line, and separate script tags are independent". Follow-up: the same is true for a module, and a parse error anywhere in a module's import graph stops the whole graph from running.
IntermediateWhat's the difference between DOMContentLoaded, load, visibilitychange and pagehide? Which do you use for what?
So they're checkpoints in the page lifecycle. DOMContentLoaded fires when the HTML is fully parsed and deferred scripts have run, but images and other subresources may still be loading. load fires when everything has finished, including images, stylesheets, iframes and fonts. visibilitychange fires when the tab is hidden or shown. pagehide fires when the user is navigating away or the page is being put in the back/forward cache.
document.addEventListener("DOMContentLoaded", () => {
initSearchBox(); // DOM is ready; don't wait for images
});
window.addEventListener("load", () => {
measureHeroImageLayout(); // need real image dimensions
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
saveDraft();
navigator.sendBeacon("/analytics", JSON.stringify(sessionStats));
}
});
window.addEventListener("beforeunload", (e) => {
if (form.dirty) e.preventDefault(); // "Leave site?" prompt
});
The practical lesson I've learned is about "user is leaving" logic. unload is unreliable, especially on mobile where tabs just get killed. It also stops the page from going into the back/forward cache, so Chrome is phasing it out. So analytics and "save progress" go in visibilitychange when the state becomes hidden, using sendBeacon or fetch with keepalive, so the request survives the page closing. beforeunload is only for the unsaved-changes prompt, and I add it only while the form is actually dirty.
And if my scripts are deferred or modules, I often don't need DOMContentLoaded at all, because the DOM is already parsed when they run.
IntermediateWhat happens in the creation phase? Predict the output.
console.log(typeof greet);
console.log(count);
console.log(typeof total);
console.log(limit);
var count = 5;
function greet() { return "hi"; }
var total = () => 10;
let limit = 3;
So the output is "function", then undefined, then "undefined", and then ReferenceError: Cannot access 'limit' before initialization.
Before any line runs, the engine does a creation pass over the scope and registers every declaration, and each kind is treated differently. Function declarations like greet are created in full, so typeof greet is already "function". var declarations like count and total are created and set to undefined. total holds an arrow function, but only the var is hoisted, not the assignment, so at that point it's just undefined. And let/const/class are registered but left uninitialised, in the temporal dead zone, until their line runs. That's why reading limit throws.
The same two phases repeat for every function call. A new execution context is created, and parameters, locals and this are set up before the body runs. That's why I can call a helper function declared at the bottom of a file from the top.
So in practice this is the reason I prefer const. Using a variable too early fails loudly with the TDZ error instead of quietly giving me undefined.
AdvancedHow does V8 go from bytecode to fast machine code, and what causes deoptimisation?
So V8 is a tiered JIT. Code starts in the Ignition interpreter as bytecode, which is quick to produce. While it runs, V8 records type feedback, like "this + always saw small integers" or "this property access always saw objects of this shape". As a function gets hotter, it moves up the tiers. Sparkplug is a fast baseline compiler, Maglev is a mid-tier optimiser, and TurboFan is the top-tier optimiser that uses that feedback to make assumptions and emit specialised machine code.
Deoptimisation is what happens when one of those assumptions breaks. The optimised code bails out and V8 falls back to a lower tier, maybe re-optimising later with more general, slower code.
function getX(point) { return point.x; }
for (let i = 0; i < 1e6; i++) getX({ x: i, y: i }); // one shape: fast, monomorphic
getX({ y: 1, x: 2 }); // different property order = different hidden class
getX({ x: 1, z: 3 }); // another shape
getX({ x: "1" }); // yet another: the access site goes polymorphic/megamorphic
function add(a, b) { return a + b; }
for (let i = 0; i < 1e6; i++) add(i, i); // optimised for small integers
add("a", "b"); // assumption broken: deopt
Here the key concept is hidden classes, or shapes. Objects created with the same properties in the same order share a shape, so a property access can be a fixed-offset load cached right at that call site (an inline cache). Mixing many shapes makes those caches slower.
In practice this means a few habits: initialise all properties in the constructor, in a consistent order. Avoid delete on hot objects, and set the property to undefined instead. Keep arrays homogeneous. And don't make one hot function handle wildly different types. When I actually suspect this, I profile, or run node --trace-opt --trace-deopt, rather than guessing. For most app code it simply isn't the bottleneck.
Interviewers look for: interpreter, then feedback, then speculative optimisation, then deopt when a guess is wrong, plus hidden classes and inline caches. Naming exact tiers is a bonus; the concept matters more.
AdvancedWhy does shipping a large JS bundle hurt even on a fast network?
So download is only the first cost. Every byte of JavaScript also has to be parsed and compiled, and the code that runs at startup then executes on the main thread. On a mid-range phone, that CPU work is often much bigger than the network time. A 1 MB bundle can easily mean hundreds of milliseconds of main-thread work before the page responds to input.
Engines do some things to help. V8 streams and parses scripts on a background thread while they download. It lazily parses inner functions: it only pre-parses them to check syntax and find variables, and does the full parse and compile the first time they're called. And it caches compiled bytecode for scripts that load repeatedly. But all of that only reduces the cost. Code I don't ship costs nothing.
// What I do about it
1. Route-level code splitting const Admin = lazy(() => import("./Admin"));
2. Lazy-load heavy, rarely-used await import("pdf-lib") inside the click handler
3. Tree-shakeable imports import { format } from "date-fns"
4. Audit dependencies bundle analyzer; replace moment with Intl / date-fns
5. Ship modern syntax to modern no unnecessary transpiling and polyfills
browsers (they add bytes AND parse time)
6. Measure the right thing Performance panel: "Evaluate Script",
"Compile Code", long tasks, TBT and INP
In one project, our Lighthouse score was poor even though the bundle downloaded in well under a second on 4G. The Performance panel showed about 900 ms of "Evaluate Script" on a throttled CPU. Most of it was a charting library and a locale bundle that only one page used. Splitting them out cut Total Blocking Time by more than half.
So in practice I treat JS bytes as more expensive than image bytes. An image gets decoded off the main thread, but a script has to be parsed, compiled and executed on it.
AdvancedA button handler processes 50,000 rows and the page freezes. What's happening, and how would you fix it?
So the handler is one long task on the main thread. While it runs, the call stack never empties, so the event loop can't render frames, handle clicks or run other callbacks. The page is frozen until it finishes. It also directly hurts INP, the responsiveness metric.
My fix depends on the work. If it has to stay on the main thread, I break it into chunks and yield between them, so the browser can render and handle input.
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield(); // Chromium, keeps priority
return new Promise((resolve) => setTimeout(resolve, 0)); // fallback: a new macrotask
}
async function processAll(rows) {
const results = [];
let lastYield = performance.now();
for (const row of rows) {
results.push(transform(row));
if (performance.now() - lastYield > 50) { // stay under ~50 ms per task
await yieldToMain();
lastYield = performance.now();
}
}
return results;
}
Here I yield based on time rather than a fixed row count, so it adapts to slow devices.
The gotcha is what not to yield with. await Promise.resolve() or queueMicrotask doesn't help at all. Microtasks run before the browser gets a chance to render, so the page stays frozen. I need a real macrotask boundary, which is what setTimeout or scheduler.yield() gives me.
If it's genuinely heavy CPU work, like parsing a big CSV or computing aggregates, I move it to a Web Worker, and the main thread stays completely free. If the problem is rendering 50,000 DOM rows, the fix is virtualisation, meaning I only render what's visible, not chunking.
Interviewers look for: linking the freeze to the event loop (one long task blocks rendering), knowing that microtasks don't yield, and choosing between chunking, a Web Worker and virtualisation based on where the cost actually is.
Predict the Output
BasicWhat does this log? (closures in a loop with var vs let)
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var', i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let', j), 0);
}
Output: var 3, var 3, var 3, then let 0, let 1, let 2
OK, so first, both loops run to completion synchronously. The six callbacks only run later, from the timer queue, in the order they were scheduled.
With var there's exactly one i for the whole function, and all three arrow functions close over that same variable. By the time they run, the loop has pushed it to 3, so I get 3 three times. With let, the spec creates a fresh binding of j for every iteration and copies the value forward, so each callback captures its own j: 0, 1, 2.
Before let existed, the fix was an IIFE, (function (k) { setTimeout(() => console.log(k)); })(i), or passing i as the third argument to setTimeout. Today I'd just use let.
Interviewers look for: the words "one shared binding" vs "a new binding per iteration". Saying "let is block scoped" alone doesn't explain it, because the loop body is the same block every time.
BasicWhat does this log? (a function and a var with the same name)
// classic script, not an ES module
console.log(typeof foo);
var foo = 1;
function foo() {}
console.log(typeof foo);
Output: function then number
So during the creation phase, the engine hoists both declarations. The function declaration is hoisted with its whole body. var foo is also hoisted, but a var declaration never overwrites a binding that already exists, so foo is still the function when the first line runs. That's why I get "function".
Then execution goes top to bottom. The assignment foo = 1 actually runs and replaces the function. The function foo() {} line does nothing at runtime, because it was already handled during hoisting. So the second log is "number".
The rule I remember is that function declarations win during hoisting, and assignments win at runtime. In real code a linter's no-redeclare rule catches this kind of collision. Also, inside an ES module this exact snippet is a SyntaxError, because top-level function declarations there can't share a name with a var, and neither can let/const.
BasicWhat does this log? (the temporal dead zone)
let x = 'outer';
function show() {
console.log(x);
let x = 'inner';
}
show();
Output: ReferenceError: Cannot access 'x' before initialization
A lot of people expect 'outer' here, and that's the trap. Inside show, the let x declaration is hoisted to the top of the function's scope. So from the first line of the function, the name x refers to the inner binding and shadows the outer one. It just hasn't been initialized yet.
The time between entering the scope and reaching the let line is the temporal dead zone. Touching the variable in that window throws. With var it would log undefined, because var is initialized to undefined when it's hoisted.
So it isn't true that let isn't hoisted. It is hoisted, just not initialized. Even typeof x would throw inside the TDZ, which is different from an undeclared variable, where typeof safely returns "undefined".
Interviewers look for: "hoisted but uninitialized", not "let isn't hoisted". The shadowing is the evidence that it is hoisted.
BasicWhat does this log? (this in a method, an arrow, and a detached method)
// running as an ES module (strict mode)
const user = {
name: 'Ada',
regular() { return this?.name; },
arrow: () => this?.name,
};
console.log(user.regular());
console.log(user.arrow());
const detached = user.regular;
console.log(detached());
Output: Ada, undefined, undefined
For a normal function, this is decided at the call site. user.regular() has user to the left of the dot, so this is user and I get 'Ada'.
The arrow function has no this of its own. It takes this from the scope it was written in, and an object literal doesn't create a scope. So that's module top level, where this is undefined. The dot at the call site doesn't matter for arrows.
For detached(), I copied the function out, so it's called with nothing to the left of the dot. In strict mode that makes this undefined. I used ?. so the snippet logs something. Without it, the last two lines would throw a TypeError. This is the classic bug when you pass obj.method as a callback, like setTimeout(user.regular) or onClick={this.handle}. The fix is user.regular.bind(user) or wrapping it: () => user.regular().
Interviewers look for: "call site for regular functions, lexical for arrows". A common follow-up: "so should object methods be arrows?" The answer is no, precisely because they can't see the object.
IntermediateWhat does this log? (async/await, setTimeout, Promise and queueMicrotask)
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
(async () => {
console.log('4');
await null;
console.log('5');
})();
queueMicrotask(() => console.log('6'));
console.log('7');
Output: 1 4 7 3 5 6 2
OK, so first the synchronous logs run. '1' logs. The setTimeout callback goes to the macrotask (timer) queue. The .then callback goes to the microtask queue, since the promise is already resolved. Then I call the async function, and its body runs synchronously up to the first await, so '4' logs right away.
The await null pauses the function and queues its continuation as a microtask, behind the '3' callback. Next, queueMicrotask adds '6' behind that. Then '7' logs, and the call stack is empty.
Now the event loop drains all microtasks in FIFO order: '3', '5', '6'. Only after that queue is empty does it take one macrotask, so '2' comes last, even with a delay of 0.
The two things people miss are that an async function runs synchronously until its first await, and that a zero-delay timeout still waits behind every microtask.
Interviewers look for: clear naming of the queues. A common follow-up is await somePromise vs await nonPromise. Since the V8 optimizations in 2019, both take a single microtask tick when the awaited value is a native promise.
BasicWhat does this log? (typeof edge cases)
console.log(typeof null);
console.log(typeof function () {});
console.log(typeof class {});
console.log(typeof NaN);
console.log(typeof []);
console.log(typeof notDeclaredAnywhere);
Output: object, function, function, number, object, undefined
Let me go line by line. typeof null is "object". That's a bug from the very first JavaScript engine, where the type tag for objects was 0 and null was the null pointer, also 0. It can't be fixed without breaking the web. So to check for null I always use === null.
Functions get their own answer, "function", even though they're objects, because they're callable. Classes are functions under the hood, so they're "function" too. NaN is literally a numeric value, "not a number" in IEEE 754 terms, so it's "number". Arrays are plain objects to typeof, which is why I use Array.isArray.
The last one is interesting: typeof on an undeclared name doesn't throw, it returns "undefined". That's the one safe way to probe for a global that may not exist, like typeof window !== 'undefined' in code that runs on both server and client.
IntermediateWhat does this log? ([] + {} and friends)
console.log([] + {});
console.log({} + []);
console.log([] + []);
console.log(1 + '2', '3' - 1);
console.log(eval('{} + []'));
Output: [object Object], [object Object], "" (an empty line), 12 2, 0
The rule for + is: convert both sides to primitives first. If either one is then a string, it concatenates. Otherwise it adds numbers. An array becomes a primitive through toString, which joins its elements, so [] becomes "". A plain object becomes "[object Object]".
So [] + {} is "" + "[object Object]". Inside console.log(...), {} + [] is an expression too, so it gives the same string. [] + [] is two empty strings, which gives an empty string. 1 + '2' concatenates to "12", but - only does math, so '3' - 1 is 2.
The famous "{} + [] is 0" only happens when it sits at the start of a statement, which I force here with eval. Then {} is parsed as an empty block, not an object, and what's left is +[], unary plus on an empty array, which is Number(""), so 0.
Interviewers look for: ToPrimitive, then "string wins for +". Mentioning that {} at statement start is a block is a strong senior signal.
BasicWhat does this log? (0.1 + 0.2)
console.log(0.1 + 0.2);
console.log(0.1 + 0.2 === 0.3);
console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON);
console.log((0.1 + 0.2).toFixed(2));
Output: 0.30000000000000004, false, true, "0.30"
So every JS number is a 64-bit IEEE 754 double, and just like 1/3 in decimal, 0.1 and 0.2 have no exact binary representation. Each one is stored as the nearest double. When you add them, the tiny errors don't cancel, and the result is the double just above 0.3. That's why the strict comparison is false.
For comparisons, I check that the difference is within a tolerance. Number.EPSILON works for values around 1, but for larger numbers I'd scale it, or pick a domain tolerance. toFixed is fine for display, but it returns a string.
In a real project, for money, I don't use floats at all. On a checkout page I worked on, we stored everything in integer cents, did all the math on integers, and only formatted with Intl.NumberFormat at the very end. For really big integers there's BigInt.
IntermediateWhat does this log? (objects used as object keys)
const cache = {};
const b = { id: 'b' };
const c = { id: 'c' };
cache[b] = 123;
cache[c] = 456;
console.log(cache[b]);
console.log(Object.keys(cache));
Output: 456, then [ '[object Object]' ]
Object property keys can only be strings or symbols. When I use an object as a key, it gets converted to a string, and every plain object stringifies to "[object Object]". So both b and c end up writing to the same key. The second write overwrites the first, and reading with b gives back 456.
The same coercion explains why obj[1] and obj['1'] are the same property.
The fix is a Map, which keys by identity:
const cache = new Map();
cache.set(b, 123);
cache.set(c, 456);
cache.get(b); // 123
And if the key objects are things like DOM nodes, which I don't want to keep alive just because they're cached, I'd use a WeakMap.
IntermediateWhat does this log? (mutating through a reference vs reassigning)
function update(person) {
person.age = 30;
person = { name: 'New' };
person.age = 99;
}
const p = { name: 'Ada', age: 25 };
update(p);
console.log(p);
const list = [1, 2, 3];
const alias = list;
alias.push(4);
console.log(list.length);
Output: { name: 'Ada', age: 30 }, then 4
JavaScript is always pass-by-value, but for objects, the value being passed is a reference. So inside update, person is a local variable holding a copy of the reference to the same object that p points to.
The first line mutates that shared object, so p.age becomes 30. The second line reassigns the local person to a brand-new object. That only changes where the local variable points, and p is unaffected. So the 99 goes onto an object that's thrown away.
The array part is the same idea. alias and list are two names for one array, so the push is visible through both. In React this is exactly why mutating state and calling setState with the same reference doesn't re-render: the equality check sees the same object. So I copy with spread or structuredClone instead.
Interviewers look for: the difference between "mutate" and "reassign". If you say "objects are passed by reference", expect the follow-up "then why didn't reassignment change p?"
IntermediateWhat does this log? (['1','2','3'].map(parseInt))
console.log(['1', '2', '3'].map(parseInt));
console.log(['10', '10', '10'].map(parseInt));
console.log(['1', '2', '3'].map(Number));
Output: [ 1, NaN, NaN ], [ 10, NaN, 2 ], [ 1, 2, 3 ]
The catch is that map calls its callback with three arguments, (element, index, array), and parseInt takes a second argument, the radix. So what actually runs is parseInt('1', 0), parseInt('2', 1), parseInt('3', 2).
A radix of 0 is treated as "not given", so the default of base 10 gives 1. Radix 1 is invalid, since bases must be 2 to 36, so the result is NaN. In radix 2 the only digits are 0 and 1, so '3' has no valid digits, which is NaN again. For the second line: '10' in base 10 is 10, base 1 is NaN, and base 2 is 2.
Number only looks at its first argument, so it works. In practice I write arr.map(s => parseInt(s, 10)) or just map(Number). The general lesson is to be careful passing a function reference straight into map when that function has optional parameters.
BasicWhat does this log? (sort without a comparator)
const nums = [10, 1, 5, 100, 25];
console.log(nums.sort());
console.log(nums.sort((a, b) => a - b));
console.log(nums);
Output: [ 1, 10, 100, 25, 5 ], [ 1, 5, 10, 25, 100 ], [ 1, 5, 10, 25, 100 ]
Without a comparator, sort converts each element to a string and compares them by UTF-16 code units, like a dictionary. So "100" comes before "25", because '1' is less than '2', and "25" comes before "5". That gives the weird first line.
With (a, b) => a - b, a negative result puts a first, so I get numeric ascending order. The third line shows the other gotcha: sort works in place and returns the same array, so nums itself was changed.
If I need to keep the original, say it's React state or a prop, I use the ES2023 toSorted, which returns a new array: nums.toSorted((a, b) => a - b). For strings from users, I'd sort with a.localeCompare(b) or an Intl.Collator so accents and case are handled properly.
BasicWhat does this log? (closure counter)
function makeCounter() {
let count = 0;
return {
inc: () => ++count,
get: () => count,
};
}
const c1 = makeCounter();
const c2 = makeCounter();
c1.inc();
c1.inc();
c2.inc();
console.log(c1.get(), c2.get());
const { inc } = c1;
inc();
console.log(c1.get());
Output: 2 1, then 3
Every call to makeCounter creates a new scope with its own count, and the returned functions close over that scope. So c1 and c2 have completely independent counters. c1 was incremented twice and c2 once, which gives 2 1.
The destructuring part is a small trick question. Normally, pulling a method off an object breaks this. But these functions don't use this at all. They reach count through the closure, so inc still works on its own and bumps c1's count to 3.
That's the real advantage of the closure pattern over a class with a this.count field: the state is truly private, and nothing outside can touch count except through those two functions. It's the same idea React hooks rely on.
IntermediateWhat does this log? (delete on an array and changing length)
const arr = [1, 2, 3, 4];
delete arr[1];
console.log(arr.length);
console.log(arr[1], 1 in arr);
console.log(arr);
arr.length = 2;
console.log(arr);
Output: 4, undefined false, [ 1, <1 empty item>, 3, 4 ], [ 1, <1 empty item> ]
delete removes a property, and array indices are just properties. So it removes the key "1" but doesn't shift anything or touch length. The length stays 4, and I'm left with a hole. Reading arr[1] gives undefined, but 1 in arr is false, which shows it's a real hole and not a stored undefined. Node prints it as "empty item".
length is writable, and setting it smaller truncates the array, dropping 3 and 4. The hole at index 1 is still there.
Holes behave inconsistently. forEach and map skip them, but for...of and spread treat them as undefined. So in real code I never use delete on arrays. I'd use splice(1, 1) to remove in place, or toSpliced / filter when I want a new array.
Interviewers look for: "delete removes a property, it doesn't reindex". A good follow-up to have ready is arr.length = 0 as a way to clear an array that other code holds a reference to.
Polyfills & Machine Coding
IntermediateImplement debounce, with a leading-edge option and cancel.
So debounce means "only run after the calls have stopped for wait ms". I'll keep a single timer in a closure and reset it on every call. The leading option fires on the first call of a burst instead of waiting.
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timer = null;
let lastArgs;
let lastThis;
function debounced(...args) {
lastArgs = args;
lastThis = this;
const isStartOfBurst = timer === null;
// every call pushes the deadline back
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
// only fire trailing if calls came in after the leading one
if (trailing && lastArgs) fn.apply(lastThis, lastArgs);
lastArgs = lastThis = undefined;
}, wait);
if (leading && isStartOfBurst) {
fn.apply(this, args);
lastArgs = lastThis = undefined; // consumed by the leading call
}
}
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
lastArgs = lastThis = undefined;
};
return debounced;
}
// usage: search box
const onInput = debounce(e => fetchResults(e.target.value), 300);
input.addEventListener('input', onInput);
A few edge cases I'm handling here:
- I use a regular
functionandapply, so the originalthisand all the arguments are preserved. An arrow wrapper would losethisif someone uses it as a method. - The trailing call uses the latest arguments, which is what you want for a search box.
- With
leading: true, a single click fires exactly once, not once at the start and again at the end. cancelmatters in React. I call it in the effect cleanup so a debounced call doesn't fire after the component unmounts.
Interviewers look for: preserving this/args, and the follow-up "how would you use this in React?" There the answer is useMemo or useRef, so the debounced function isn't recreated on every render.
IntermediateImplement throttle so it fires at most once per interval, including a trailing call.
Throttle is different from debounce. It guarantees a steady rate, like at most once every 100 ms while you're scrolling, instead of waiting for silence. I'll track when it last ran, and if a call comes in too early, I'll schedule one trailing call for the remaining time.
function throttle(fn, wait) {
let lastRun = 0;
let timer = null;
let pendingArgs;
let pendingThis;
return function throttled(...args) {
const now = Date.now();
const remaining = wait - (now - lastRun);
if (remaining <= 0) {
// enough time has passed: run right now
clearTimeout(timer);
timer = null;
lastRun = now;
fn.apply(this, args);
} else {
// too soon: remember the latest call and run it when the window ends
pendingArgs = args;
pendingThis = this;
if (!timer) {
timer = setTimeout(() => {
lastRun = Date.now();
timer = null;
fn.apply(pendingThis, pendingArgs);
pendingArgs = pendingThis = undefined;
}, remaining);
}
}
};
}
window.addEventListener('scroll', throttle(updateProgressBar, 100));
A few edge cases I'm handling here:
- The first call runs right away, because
lastRunstarts at 0. - The trailing call matters. Without it, if the user stops scrolling in the middle of a window, the last position is never processed and the progress bar sits slightly off.
- I only ever have one pending timer, and it always uses the latest arguments.
- If a timer fires late, which happens in background tabs, the
remaining <= 0branch clears it, so the function can't run twice.
For anything tied to painting, like scroll-linked animation, I'd actually throttle with requestAnimationFrame, which lines up with the display's refresh.
Interviewers look for: a crisp debounce vs throttle distinction with a real use case for each: debounce for search and autosave, throttle for scroll, resize, and drag.
IntermediateWrite a polyfill for Promise.all.
So Promise.all resolves with all the values in input order once everything fulfills, and rejects as soon as any one rejects. I'll store results by index and count how many are still pending, because promises can settle in any order.
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const items = Array.from(iterable); // accept any iterable, not just arrays
const results = new Array(items.length);
let pending = items.length;
if (pending === 0) {
resolve(results); // Promise.all([]) resolves immediately
return;
}
items.forEach((item, i) => {
// wrap so plain values and thenables work too
Promise.resolve(item).then(value => {
results[i] = value; // by index, not push
if (--pending === 0) resolve(results);
}, reject); // first rejection wins
});
});
}
promiseAll([fetchUser(), 42, fetchPosts()]).then(([user, n, posts]) => { /* ... */ });
A few edge cases I'm handling here:
- I write to
results[i]instead of pushing, because a push would store values in completion order, and that's the most common bug in this question. - An empty input resolves right away. If I didn't special-case it, the counter would never reach zero and the promise would hang forever.
Promise.resolve(item)means non-promise values like42pass straight through.- Calling
rejectmore than once is harmless, because a promise can only settle once.
One thing worth mentioning: after a rejection, the other promises keep running. Promise.all doesn't cancel anything. If I needed cancellation, I'd pass an AbortController signal into the requests.
Interviewers look for: index-based results, the empty-array case, and wrapping in Promise.resolve. A likely follow-up is "now make it allSettled".
IntermediateWrite a polyfill for Promise.allSettled.
allSettled never rejects. It waits for everything and describes each outcome. The neat way to write it is to turn every rejection into a fulfilled "description" object, and then reuse the Promise.all logic.
function promiseAllSettled(iterable) {
const wrapped = Array.from(iterable, item =>
Promise.resolve(item).then(
value => ({ status: 'fulfilled', value }),
reason => ({ status: 'rejected', reason })
)
);
// none of the wrapped promises can reject, so all() just collects them
return Promise.all(wrapped);
}
const results = await promiseAllSettled([
fetch('/api/a'),
Promise.reject(new Error('nope')),
7,
]);
// [ { status: 'fulfilled', value: Response },
// { status: 'rejected', reason: Error: nope },
// { status: 'fulfilled', value: 7 } ]
const failed = results.filter(r => r.status === 'rejected');
A few edge cases I'm handling here: the output order still matches the input, because Promise.all preserves it. The shape matches the spec exactly, value for fulfilled and reason for rejected, so code written against the native version works unchanged. And an empty input resolves to [].
When I'd use it: on a dashboard that loads six independent widgets, I don't want one failing endpoint to blank the whole page. So I use allSettled, render what succeeded, and show an error state only on the widgets that failed.
IntermediateWrite a polyfill for Promise.race, and use it to add a timeout to fetch.
race settles the same way as whichever input settles first, whether that's a fulfillment or a rejection. The implementation is tiny: I attach the same resolve and reject to every input, and the first one to call them wins.
function promiseRace(iterable) {
return new Promise((resolve, reject) => {
for (const item of iterable) {
Promise.resolve(item).then(resolve, reject);
}
});
}
// practical use: timeout
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
});
return promiseRace([promise, timeout]).finally(() => clearTimeout(timer));
}
await withTimeout(fetch('/api/slow'), 5000);
A few edge cases I'm handling here. The first settlement wins, and later calls to resolve/reject are ignored automatically. An empty iterable gives a promise that stays pending forever, which is spec behaviour, and I'd mention it because it surprises people. In the timeout helper, I clear the timer in finally, so a fast response doesn't leave a timer running.
The honest caveat is that race doesn't stop the losing request. The fetch keeps going in the background. For real cancellation, I'd use fetch(url, { signal: AbortSignal.timeout(5000) }), which actually aborts the request.
Interviewers look for: knowing race vs any. any ignores rejections and only rejects, with an AggregateError, if all of them fail.
AdvancedImplement Function.prototype.call, apply and bind yourself.
The trick for call is to use the rule that "this is whatever is left of the dot". I temporarily attach the function to the context object as a property and invoke it as a method. apply is just call with an array. bind returns a closure that remembers the context and any preset arguments.
Function.prototype.myCall = function (ctx, ...args) {
// sloppy-mode semantics: null/undefined become globalThis, primitives get boxed
ctx = ctx == null ? globalThis : Object(ctx);
const key = Symbol('fn'); // unique key, can't clash with real props
ctx[key] = this; // "this" here is the function being called
try {
return ctx[key](...args); // called as a method, so this === ctx
} finally {
delete ctx[key]; // clean up even if fn throws
}
};
Function.prototype.myApply = function (ctx, args) {
return this.myCall(ctx, ...(args ?? []));
};
Function.prototype.myBind = function (ctx, ...preset) {
const fn = this;
function bound(...later) {
// used with "new": ignore the bound this and construct normally
if (new.target) return new fn(...preset, ...later);
return fn.myCall(ctx, ...preset, ...later);
}
if (fn.prototype) bound.prototype = Object.create(fn.prototype);
return bound;
};
function greet(greeting, mark) { return `${greeting}, ${this.name}${mark}`; }
greet.myCall({ name: 'Ada' }, 'Hi', '!'); // "Hi, Ada!"
greet.myApply({ name: 'Ada' }, ['Hey', '?']); // "Hey, Ada?"
greet.myBind({ name: 'Ada' }, 'Yo')('.'); // "Yo, Ada."
A few edge cases I'm handling here:
- I use a
Symbolkey, so I can't overwrite an existing property calledfnon the object. - The
try/finallycleans up even if the function throws. bindsupports partial application, meaning preset args plus later args.- I handle
newon a bound function, which the spec says should ignore the boundthis.
The known limitations, which I'd say out loud: I can't reproduce strict-mode this === undefined with this trick, and it fails on frozen objects, where I can't add a property. The native versions don't have either problem.
Interviewers look for: the "attach as a method" insight and partial application in bind. Handling new on a bound function is what separates senior answers.
AdvancedWrite a deep clone that handles nested objects, arrays, Dates, Maps, Sets and circular references.
I'll recurse through the structure, and to handle cycles I'll keep a WeakMap from each original object to its copy. If I see an object again, I return the copy I already made instead of recursing forever.
function deepClone(value, seen = new WeakMap()) {
// primitives and functions are returned as-is
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value); // cycle or shared ref
if (value instanceof Date) return new Date(value);
if (value instanceof RegExp) return new RegExp(value.source, value.flags);
if (value instanceof Map) {
const out = new Map();
seen.set(value, out); // register BEFORE recursing
value.forEach((v, k) => out.set(deepClone(k, seen), deepClone(v, seen)));
return out;
}
if (value instanceof Set) {
const out = new Set();
seen.set(value, out);
value.forEach(v => out.add(deepClone(v, seen)));
return out;
}
// arrays and objects: keep the prototype (so class instances stay instances)
const out = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
seen.set(value, out);
for (const key of Reflect.ownKeys(value)) { // includes symbol keys
if (Object.prototype.propertyIsEnumerable.call(value, key)) {
out[key] = deepClone(value[key], seen);
}
}
return out;
}
const a = { name: 'root', tags: new Set(['x']), created: new Date() };
a.self = a;
const b = deepClone(a);
console.log(b.self === b, b !== a, b.tags !== a.tags); // true true true
A few edge cases I'm handling here:
- I register the copy in
seenbefore recursing. That's what makes cycles work, because the child can find its parent's copy while the parent is still being built. - Shared references stay shared: if two properties point to the same object, the clone has two properties pointing to the same copy.
- Symbol keys are copied, and the prototype is kept.
- I skip non-enumerable props, like array
length, on purpose.
In real code, I'd first reach for the built-in structuredClone. It handles cycles, Maps, Sets, Dates and typed arrays. But it throws on functions and DOM nodes, and it drops prototypes, so class instances come back as plain objects. That's exactly the gap this version covers. And JSON.parse(JSON.stringify(x)) I'd avoid, because it loses Dates, undefined, Maps, and crashes on cycles.
Interviewers look for: the WeakMap cycle guard, set before recursion. They'll often ask why a WeakMap and not a Map. The answer is that it doesn't keep the originals alive after the clone finishes.
AdvancedImplement deepEqual(a, b).
I'll first short-circuit on identical values, then make sure both are objects with the same prototype, then compare own keys recursively. I'll also carry a cycle guard so self-referencing structures don't overflow the stack.
function deepEqual(a, b, seen = new WeakMap()) {
if (Object.is(a, b)) return true; // same ref, or NaN vs NaN
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false; // different primitives
}
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false; // [] vs {}
if (a instanceof Date) return a.getTime() === b.getTime();
if (a instanceof RegExp) return String(a) === String(b);
// cycle guard: if we're already comparing this exact pair, assume equal
if (seen.get(a) === b) return true;
seen.set(a, b);
const keysA = Reflect.ownKeys(a);
const keysB = Reflect.ownKeys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.hasOwn(b, key)) return false;
if (!deepEqual(a[key], b[key], seen)) return false;
}
return true;
}
deepEqual({ x: [1, { y: 2 }] }, { x: [1, { y: 2 }] }); // true
deepEqual([1, 2], { 0: 1, 1: 2 }); // false (prototype differs)
deepEqual({ a: undefined }, {}); // false (key count differs)
A few edge cases I'm handling here:
- I use
Object.issoNaNequalsNaN. The trade-off is that0and-0count as different. If I wanted them equal, I'd add an explicit===check. - Comparing prototypes stops an array from equalling an array-like object.
- Comparing key counts catches
{ a: undefined }vs{}, which naive "loop over a's keys" versions get wrong. - Arrays work for free, since
lengthis an own key.
I've left out Map and Set. For those, I'd compare sizes and then check each entry, and Set equality with object members gets expensive, so I'd ask the interviewer whether they're in scope.
IntermediateImplement memoize.
So memoize wraps a pure function and caches results by their arguments. The main design decision is the cache key. I'll default to JSON.stringify of the arguments, but let the caller pass their own resolver, and I'll use a Map for the cache.
function memoize(fn, resolver = (...args) => JSON.stringify(args)) {
const cache = new Map();
function memoized(...args) {
const key = resolver(...args);
if (cache.has(key)) return cache.get(key); // has(), so cached undefined still hits
const result = fn.apply(this, args);
cache.set(key, result);
// for async functions: don't keep a rejected promise in the cache
if (result && typeof result.then === 'function') {
result.then(null, () => cache.delete(key));
}
return result;
}
memoized.cache = cache; // allow clearing from outside
return memoized;
}
// recursive use: call the memoized version inside so sub-results are cached too
const fib = memoize(n => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
fib(90); // instant, instead of billions of calls
const getUser = memoize(id => fetch(`/api/users/${id}`).then(r => r.json()));
A few edge cases I'm handling here:
- I check with
cache.hasrather than looking for a truthy value, so results like0,falseorundefinedstill count as cache hits. - For async functions, caching the promise also removes duplicate in-flight requests: two components asking for user 5 at the same moment share one fetch. And I evict on rejection, so one network blip isn't cached forever.
- For recursion, the function has to call the memoized version, otherwise only the top-level call is cached.
The trade-offs I'd mention: JSON.stringify can't tell objects apart by identity and is slow for big arguments, and the cache grows forever. In production I'd add an LRU limit or a TTL, or use a WeakMap when the argument is a single object.
Interviewers look for: key strategy, unbounded memory growth, and "only memoize pure functions". Caching a promise to dedupe requests is a strong practical point.
AdvancedImplement curry, and then an infinite sum like sum(1)(2)(3)().
For a normal curry, I use fn.length to know how many arguments the function expects. I keep collecting arguments across calls until I have enough, then call the original. The infinite version has no fixed arity, so the stop signal becomes an empty call.
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args); // have enough: call it
}
// not enough yet: return a function that collects more
return (...more) => curried.apply(this, [...args, ...more]);
};
}
const add3 = curry((a, b, c) => a + b + c);
add3(1)(2)(3); // 6
add3(1, 2)(3); // 6
add3(1)(2, 3); // 6
// infinite curry: keep adding until called with no argument
function sum(a) {
return function next(b) {
return b === undefined ? a : sum(a + b);
};
}
sum(1)(2)(3)(); // 6
sum(5)(); // 5
const base = sum(10);
base(1)(); // 11
base(2)(); // 12 (base is not mutated)
A few edge cases I'm handling here:
- The curry accepts any grouping of arguments, which is what people usually mean by "curry" in JS.
- In
sum, each step returns a new function with the running total baked in, instead of mutating a shared variable. That's why thebaseexample gives 11 and then 12. The common mutable version gives 11 and then 13.
The gotcha with fn.length is that it doesn't count default parameters or rest parameters. So curry((a, b = 2) => …) sees a length of 1. For those, I'd take an explicit arity argument.
There's also a variant where you log sum(1)(2)(3) without the final (). That's done by overriding valueOf/toString on the returned function, but I'd mention it rather than recommend it.
Interviewers look for: fn.length and its limits, and an immutable running total in the infinite version. Expect "where would you use currying?" A good answer is building reusable, preconfigured functions, like const logError = log('error').
IntermediateFlatten a nested array to a given depth, and flatten a nested object into dot-path keys.
They're the same recursive idea. For arrays, I recurse into anything that's an array while there's depth left, and I pass one output array down so I'm not creating and spreading lots of temporary arrays. For objects, I carry the key path down as a prefix.
function flatten(arr, depth = Infinity, out = []) {
for (const item of arr) {
if (Array.isArray(item) && depth > 0) {
flatten(item, depth - 1, out);
} else {
out.push(item);
}
}
return out;
}
flatten([1, [2, [3, [4]]], 5]); // [1, 2, 3, 4, 5]
flatten([1, [2, [3, [4]]], 5], 1); // [1, 2, [3, [4]], 5]
function flattenObject(obj, prefix = '', out = {}) {
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
const isNested =
value !== null &&
typeof value === 'object' &&
!(value instanceof Date) &&
Object.keys(value).length > 0; // keep {} and [] as leaf values
if (isNested) flattenObject(value, path, out);
else out[path] = value;
}
return out;
}
flattenObject({ a: 1, b: { c: 2, d: { e: 3 } }, f: [4, 5], g: {} });
// { a: 1, 'b.c': 2, 'b.d.e': 3, 'f.0': 4, 'f.1': 5, g: {} }
A few edge cases I'm handling here:
nullis excluded explicitly, sincetypeof nullis"object".- Dates are treated as leaf values, not walked into.
- Empty objects and arrays are kept as values instead of silently disappearing.
- The depth parameter matches
Array.prototype.flat.
One difference from the native flat: it skips holes in sparse arrays, while my for...of turns them into undefined. For very deep nesting, I'd switch to an iterative version with an explicit stack to avoid a stack overflow.
Where I've used the object version: turning a nested form's error object into field paths like address.city, and flattening config before sending it to an analytics tool that only accepts flat keys.
IntermediateBuild an EventEmitter with on, off, emit and once.
I'll keep a Map from event name to an array of listeners. The interesting part is once: I'll register a wrapper that removes itself before calling the real listener, and I'll tag the wrapper with the original listener so off(name, original) still works.
class EventEmitter {
#events = new Map(); // name -> listener[]
on(name, listener) {
if (!this.#events.has(name)) this.#events.set(name, []);
this.#events.get(name).push(listener);
return () => this.off(name, listener); // handy unsubscribe function
}
off(name, listener) {
const list = this.#events.get(name);
if (!list) return this;
const i = list.findIndex(l => l === listener || l.original === listener);
if (i !== -1) list.splice(i, 1);
if (list.length === 0) this.#events.delete(name);
return this;
}
once(name, listener) {
const wrapper = (...args) => {
this.off(name, wrapper); // remove first, in case listener re-emits
listener.apply(this, args);
};
wrapper.original = listener;
return this.on(name, wrapper);
}
emit(name, ...args) {
const list = this.#events.get(name);
if (!list) return false;
// iterate a copy: once-listeners remove themselves mid-loop
for (const listener of [...list]) listener.apply(this, args);
return true;
}
}
const bus = new EventEmitter();
const stop = bus.on('save', doc => console.log('saved', doc.id));
bus.once('save', () => console.log('first save only'));
bus.emit('save', { id: 1 }); // saved 1, first save only
bus.emit('save', { id: 2 }); // saved 2
stop();
A few edge cases I'm handling here:
- The big one is iterating over a copy in
emit. If I looped over the live array while aoncelistener splices itself out, the next listener would be skipped. - The wrapper removes itself before calling the listener, so if the listener emits the same event again, it doesn't run twice.
offremoves one registration at a time, which matches Node.- The private
#eventsfield keeps the internals out of reach. - Returning an unsubscribe function from
onmakes cleanup easy in a React effect.
Interviewers look for: the mutate-during-iteration bug and the once/off interaction. Common follow-ups: error isolation, where one throwing listener shouldn't stop the rest (wrap each call in try/catch), and memory leaks from listeners that are never removed.
AdvancedWrite an async task runner that runs at most N tasks at a time.
So I get an array of functions that return promises, and I want at most limit running at once, with results in input order. My approach is a worker pool: start limit workers, and each one keeps pulling the next task index from a shared counter until there's nothing left.
async function runWithLimit(tasks, limit) {
const results = new Array(tasks.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < tasks.length) {
const i = nextIndex++; // claimed synchronously, so no two workers share an index
results[i] = await tasks[i](); // tasks are functions, so they start only when called
}
}
const workerCount = Math.min(limit, tasks.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}
// usage: upload 50 files, 4 at a time
const tasks = files.map(file => () => upload(file));
const urls = await runWithLimit(tasks, 4);
Let me explain why this is safe. JavaScript is single-threaded, so nextIndex++ runs without interruption. Two workers can never claim the same task. As soon as one task finishes, that worker immediately takes the next one, so there are always limit tasks in flight until the queue runs dry. That's better than splitting the work into batches of N, where a whole batch waits on its slowest task.
A few edge cases I'm handling here:
- The tasks are functions, not promises. If they were promises, they'd all have started already and the limit would mean nothing. That's the most common mistake in this question.
- Results are stored by index, so they come back in input order.
Math.minmeans I don't start idle workers when there are fewer tasks than the limit.- An empty list returns
[].
On errors: as written, the first failure rejects the whole thing, like Promise.all, but the other workers keep draining the queue. For something like bulk uploads, I'd wrap each task in a try/catch and store { status, value/reason } instead, so one bad file doesn't hide the rest.
Interviewers look for: tasks as thunks, the pool pattern vs fixed batches, and order preservation. A senior follow-up is "add a priority queue", or "make it stream results as they finish".
AdvancedImplement retry with exponential backoff.
I'll loop: try the call, and if it fails with a retryable error and I still have attempts left, wait and try again. The wait doubles each time, is capped at a maximum, and has random jitter so thousands of clients don't all retry at the same moment.
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function retry(fn, {
retries = 3, // retries AFTER the first attempt
baseDelay = 300, // ms
maxDelay = 5000,
shouldRetry = () => true,
} = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn(attempt); // "return await" so the catch sees rejections
} catch (err) {
if (attempt >= retries || !shouldRetry(err)) throw err;
const backoff = Math.min(maxDelay, baseDelay * 2 ** attempt); // 300, 600, 1200...
const delay = Math.random() * backoff; // "full jitter"
await sleep(delay);
}
}
}
// usage: only retry network errors and 5xx, never 4xx
const data = await retry(async () => {
const res = await fetch('/api/orders');
if (!res.ok) throw Object.assign(new Error(res.statusText), { status: res.status });
return res.json();
}, {
retries: 4,
shouldRetry: err => !err.status || err.status >= 500 || err.status === 429,
});
A few edge cases I'm handling here:
return awaitinside thetryis deliberate. With a plainreturn fn(), the rejection would skip my catch block.- I rethrow the original error after the last attempt, so callers see the real cause.
shouldRetrymatters a lot. Retrying a 400 or 401 just hammers the server with a request that will never succeed. I only retry network failures, 5xx and 429 (for 429 I'd ideally read theRetry-Afterheader).- The delay cap stops the backoff from growing into minutes.
The thing I'd flag in a real system is idempotency. Retrying a GET is safe, but retrying a POST that creates an order can create two orders. So for writes, I'd only retry if the API supports an idempotency key.
Interviewers look for: jitter, a max cap, not retrying client errors, and idempotency. Bonus points for supporting an AbortSignal so the user can cancel mid-backoff.