Overview
Curated: · Written: · Reviewed:
JavaScript fundamentals
Senior-level JavaScript interviews are not about syntax. They are about the execution model: what a binding holds, what a closure keeps alive, how this is chosen, and how the event loop orders work that isn't synchronous. Almost every "weird JavaScript" question reduces to one of those four. This guide builds the model first, then the rules that fall out of it, with runnable examples whose outputs are traced. Each section ends with what interviewers probe and what a weak answer sounds like.
The one mental model to carry: JavaScript runs your code one call stack at a time, and everything else — closures, this, promises, the event loop — is a consequence of when bindings are created, when they're read, and when the stack gets to them.
Values, references, and copying
Every binding holds a value. For primitives (numbers, strings, booleans, null, undefined, symbols, BigInt), that value is the thing. For objects, arrays, and functions, the value is a reference to the thing. Assignment copies what the binding holds — so primitives are copied, objects are aliased.
That single fact explains the three behaviours interviewers probe:
- Mutation is shared. Two names pointing at one object see each other's changes.
- Reassignment is not. The reference itself is passed by value, so a callee can mutate the caller's object but cannot rebind the caller's variable.
- Equality is identity for objects. Two structurally identical objects are never
===, because===on objects compares the references.
function mutate(o) { o.n = 2; }
function reassign(o) { o = { n: 3 }; }
const box = { n: 1 };
mutate(box); // box.n === 2: mutation crosses the call
reassign(box); // box.n === 2: the parameter was rebound, box wasn't
{ n: 2 } === { n: 2 }; // false: identity, not structure
Copying has two depths and they fail differently. Spread and Object.assign copy one level: nested objects stay shared, so mutating copy.n.v changes original.n.v. structuredClone walks the whole structure, handles cycles, Map, Set, Date, and typed arrays, and throws DataCloneError on functions and DOM nodes. The JSON round trip is not a substitute: it drops undefined, turns a Date into a string, and throws on cycles.
const o = { d: new Date(0), n: { v: 1 } };
const shallow = { ...o };
shallow.n.v = 2; // o.n.v === 2: still shared
const deep = structuredClone(o);
deep.d instanceof Date; // true
Decision criteria. Decide per function whether it mutates the caller's object or returns a new one, and name it accordingly (sortInPlace vs sorted). Copy at the boundary when a value must not be shared. Use structuredClone for genuine deep copies of data; spread only where one level is provably enough.
Weak answer. "JavaScript passes objects by reference" — that phrasing implies a callee can rebind the caller's variable, which it can't. Say: the reference is passed by value.
Equality: four algorithms and when each applies
There are four equality algorithms, and a senior answer names them:
- Strict (
===) — same type and value, no conversion. Not reflexive forNaN, blind to-0. - Abstract (
==) — a conversion table, applied before comparing. - SameValue (
Object.is) — like===, butObject.is(NaN, NaN)istrueandObject.is(0, -0)isfalse. - SameValueZero — used by
Array.prototype.includesandMap/Setkeys: findsNaN, treats the zeroes as equal.
The == table is worth knowing precisely because it's the source of the surprises. Strings and booleans convert to numbers; objects convert via ToPrimitive (which calls Symbol.toPrimitive, then valueOf/toString per the hint). null and undefined equal each other and nothing else. The result is non-transitive:
0 == ""; // true
0 == "0"; // true
"" == "0"; // false
[] == 0; // true: [] -> "" -> 0
null == 0; // false: null equals only undefined
NaN === NaN; // false
Object.is(NaN, NaN); // true
[NaN].includes(NaN); // true (SameValueZero)
[NaN].indexOf(NaN); // -1 (strict)
Decision criteria. Default to ===. Allow == only in the x == null idiom (covers both absences). Use Object.is where the -0 distinction genuinely matters — the realistic case is a sign-of-zero in a rate or a direction computation. Use Number.isNaN (not global isNaN, which coerces: isNaN("abc") is true, Number.isNaN("abc") is false) to detect a failed conversion, because comparing with NaN never matches.
Weak answer. "== ignores type." Too vague to predict anything. The surprising cases come from ToPrimitive, not leniency.
Closures: bindings, not snapshots
A closure is a function plus the lexical environment it was defined in. The function keeps a reference to the bindings in that environment — not a snapshot of their values at creation time. Read the binding later, get the later value.
Internally: each function value carries a link to the environment record where it was created. As long as the function is reachable, every binding it reads stays reachable, so a small callback can hold a large object alive — a registered listener or timer is a GC root for everything the closure captures. Engines may omit names the closure never reads, but don't design around that.
The canonical case is var in a loop: one binding shared across all iterations, so every callback sees the final value. let creates a fresh binding per iteration, which is the fix.
const fns = [];
for (var i = 0; i < 3; i++) fns.push(() => i);
fns.map((f) => f()); // [3, 3, 3]: one shared binding
const gns = [];
for (let j = 0; j < 3; j++) gns.push(() => j);
gns.map((g) => g()); // [0, 1, 2]: a fresh binding per iteration
The same mechanism is what makes closures useful, and interviewers probe both directions:
- Encapsulation. A counter whose state is reachable only through the returned functions — genuine privacy, no
#field needed. - Memoization and
once. Capture a cache or adoneflag in the enclosing scope. - Stale closures. A React-style bug: an event handler or
setIntervalcallback captured a state value at creation and never sees updates. The fix is the same as the loop fix — give the callback a fresh binding (a dependency, a ref, or an argument) rather than the old one.
function once(fn) {
let done = false, result;
return (...args) => {
if (!done) { result = fn(...args); done = true; }
return result;
};
}
Memory failure mode. A listener that's never removed keeps its captures alive. Return an unsubscribe function from anything that registers, and take a heap snapshot across two identical user journeys when you suspect growth.
Weak answer. "A closure captures the value at creation time." That's exactly backwards, and the var-loop output [3, 3, 3] disproves it on the spot.
this: four call-site rules, and what arrows do to them
In an ordinary function, this is decided by the call site, not by where the function was written. Four rules, in precedence order:
new—thisis the freshly constructed object.- Explicit —
call,apply,bindset the receiver. - Implicit —
obj.method()setsthistoobj, the object before the dot. - Default — a plain call:
undefinedin strict mode (modules and class bodies are always strict), the global object in sloppy scripts.
Extracting a method into a variable drops the receiver — rule 3 becomes rule 4 — which is the most common production bug in this area:
const counter = { n: 0, inc() { this.n += 1; return this.n; } };
counter.inc(); // 1: implicit receiver
const loose = counter.inc;
loose(); // TypeError: this is undefined in a module
setTimeout(() => counter.inc(), 0); // 2: the wrapper restores the receiver
Arrow functions have no this of their own (and no arguments, no new.target; new on one throws). They use the this of the scope where they were written, fixed at definition. call, apply, and bind cannot retarget them. That makes arrows exactly right for callbacks inside a method — they inherit the method's receiver — and exactly wrong for object-literal methods, where the enclosing scope is the module, not the object:
const a = { n: 1, get: () => this };
a.get(); // undefined: the arrow never sees the object literal
const b = { n: 1, get() { return this.n; } };
b.get(); // 1
a.get.call(b); // still undefined: arrows ignore the receiver
bind returns a new function with the receiver fixed permanently — a second bind wraps a function whose receiver is already set, so the first bind wins. And binding inside a render path or loop creates a new function object per pass, which breaks removeEventListener (a different function was added) and any referential-equality check. Bind once, store the result.
function who() { return this.name; }
who.call({ name: "a" }); // "a"
who.bind({ name: "b" }).bind({ name: "c" })(); // "b": the first bind wins
Decision criteria. Method shorthand for object and class methods; arrows for callbacks that should inherit the current this; a bound or arrow-field handler when a method will be detached. Don't mix styles inside one object.
Weak answer. "this refers to the object the function is in." There is no such thing as the object a function is in — the same function body sees different receivers at different call sites.
The event loop: one task, then all the microtasks
The model: JavaScript runs on a single thread with one call stack. Work arrives as tasks (macrotasks: setTimeout/setInterval callbacks, I/O completion, DOM events). Each turn of the loop:
- Take one task from the macrotask queue and run it to completion.
- Drain the microtask queue completely — promise reactions,
queueMicrotask, and async-function resumptions — including microtasks queued by microtasks. - (In a browser) possibly render, then take the next task.
Microtasks always run before the next task, even a timer that's already due. Rendering happens between tasks, which is why a long synchronous block freezes the UI no matter how the work inside it was scheduled.
Trace this the way the engine does:
console.log("1");
setTimeout(() => console.log("4"), 0);
Promise.resolve().then(() => {
console.log("3a");
queueMicrotask(() => console.log("3b"));
});
console.log("2");
// 1, 2, 3a, 3b, 4
Sync code first: 1, then 2. The setTimeout callback is a task — it waits. The promise reaction is a microtask — it runs at the end of this turn, before any task. 3b was queued from inside a microtask, and the queue is drained completely, so it still runs before the timer. Only then does the task 4 run.
Failure mode at scale. A loop of microtasks queued from microtasks starves tasks and rendering indefinitely — the page never paints. Conversely, a long synchronous task blocks everything; breaking it into yielding tasks (scheduler.yield() where available, or setTimeout chunking) is the fix.
Weak answer. "setTimeout(fn, 0) runs it next." It runs it after the current task and the entire microtask queue — ordering assumptions between a timer and a resolved promise are wrong in both directions.
Promises, async/await, and choosing a combinator
A promise is a state machine: pending → fulfilled or rejected, settled exactly once. then, catch, and finally each return a new promise: a handler that returns a value resolves the next link; one that throws rejects it. A rejection travels down the chain until a handler consumes it.
async/await is syntax over that machinery: an async function returns a promise, and each await suspends the function (scheduling resumption as a microtask) while the rest of the program keeps running. try/catch around an await works; wrapping a promise-returning call in try/catch without awaiting catches nothing — the function returns before the rejection happens, and the error surfaces later as an unhandled rejection (which terminates a Node process by default).
try { failing(); } catch { /* never runs */ } // unhandled rejection
try { await failing(); } catch { /* runs */ }
The combinator choice is a real interview question with a real decision table:
| Combinator | Resolves when | Rejects when | Use when |
|---|---|---|---|
Promise.all | all fulfill | first rejection | all-or-nothing; you want fail-fast |
Promise.allSettled | all settle | never | you need every result, failures included |
Promise.race | first settles (either way) | first rejection if it's first | timeouts, first-wins |
Promise.any | first fulfillment | all reject (AggregateError) | any one success is enough |
The latency difference between shapes is the point, not a detail — ten independent 100 ms calls:
for (const id of ids) await fetchOne(id); // ~1000 ms: sequential
await Promise.all(ids.map(fetchOne)); // ~100 ms: overlapped
await does not block the thread — other tasks run; the cost is latency in this function. Await in a loop when each step depends on the last; overlap when they're independent; bound concurrency when the collection would overwhelm the dependency.
Cancellation. Promises don't have it built in — a settled promise is settled. The platform answer is AbortController: pass controller.signal to fetch (and most modern APIs), call controller.abort() to cancel, and catch the resulting AbortError. For a timeout, wire setTimeout to abort() and race the fetch against nothing — the abort rejects it for you.
Weak answer. "await blocks until it's done." It suspends one function; the event loop keeps turning. And "Promise.all runs things in parallel" — it overlaps already-started operations; it doesn't schedule them.
Likely follow-ups, and where the answers connect
Interviewers chain these topics deliberately. Expect:
- "Why does the var-loop print 3, 3, 3?" → closures capture bindings → follow-up: "how do you fix it?" →
letcreates a per-iteration binding → follow-up: "what does that cost?" → one binding per iteration, negligible; the real cost is the stale closure you didn't notice. - "What does
thislog here?" → call-site rules → follow-up: "how do you guarantee the receiver?" → bind once, or an arrow wrapper → follow-up: "why doesn't double-binding work?" → the first bind already fixed the receiver. - "Order these six log statements." → task vs microtask → follow-up: "what if a microtask queues a microtask?" → the queue drains completely → follow-up: "when does the user see anything?" → rendering happens between tasks.
- "Why is
0.1 + 0.2 !== 0.3?" → IEEE-754 doubles; integer exactness ends atNumber.MAX_SAFE_INTEGER(9007199254740991); hold money as integer minor units, compare computed floats with a deliberate tolerance, use BigInt for oversized identifiers. - "Why is
typeof null === "object"?" → a first-edition bug preserved for web compatibility; check absence with== nullor??, never truthiness —0,"", andfalseare falsy but valid.
The connective tissue: every one of these is about when a binding is read and by what. A closure reads a binding later than you expected; this is a binding filled at the call site; the event loop decides when the stack gets back to a suspended function; a promise is a value whose binding hasn't been filled yet. If you can trace, on paper, which binding each expression reads and when the engine executes it, none of the interview questions in this area can surprise you.
