Engine & memory
What V8 is actually doing while your code just runs.
Everything so far has been the language as you write it. This chapter is what the engine does with it — and it's the layer most senior-level interviews actually probe, because it's the layer where "it works" and "it works well" stop being the same question.
Stack vs heap
This is the exact primitive-vs-reference split from the types chapter, one level lower: a primitive that never leaves its function lives directly in that function's stack frame — cheap to allocate, cheap to reclaim, gone the instant the frame pops. An object always lives on the heap, and the stack only ever holds a pointer to it — which is the entire mechanical reason copying a variable copies the reference and not the data.
Each function call gets its own execution context — the formal name for what's been informally called a "scope" in every chapter so far. It bundles an environment record (the actual variable bindings) with a reference to the outer context, and that chain of outer references is the scope chain from two chapters back. A closure, mechanically, is just a function holding onto a reference to an execution context that would otherwise have been popped off the stack and discarded.
Garbage collection
JS never frees memory by counting references down to zero the moment they drop — it periodically asks a different question: reachability. Starting from a set of roots (global variables, everything currently on the call stack), the collector walks every reference it can find. Anything it never reaches is garbage, full stop — not "has zero references," which matters the instant two objects reference only each other.
function makeCycle() {
const a = {};
const b = {};
a.friend = b;
b.friend = a; // a and b reference EACH OTHER
return "created a cycle";
}
console.log(makeCycle());
// once makeCycle() returns, nothing on the stack points to a or b anymore —
// they're unreachable from any root, cycle or not, and get collected
A reference-counting collector (like older versions of Python) would
actually leak this — a and b each hold one
reference to the other, so neither ever hits zero on its own.
Reachability-based collection sidesteps that entire class of bug for
free: once makeCycle returns, nothing reachable from a
root points at either object, cycle or not, so both are simply gone.
V8 specifically runs a generational collector, built on one observation: most objects die young. New objects go into a small "young generation" that gets swept frequently and cheaply (Scavenger); anything that survives a few sweeps gets promoted to the "old generation," which is collected far less often, using a slower mark-and-sweep (mark everything reachable, then sweep away everything that wasn't marked) with an occasional mark-compact pass to defragment. Optimizing for the common case — short-lived objects — instead of treating every object identically is most of where the speed comes from.
Memory leaks — JS still has them
"Garbage collected" means unreachable memory gets freed automatically. It does not mean memory can't leak — it means every JS leak is really the same root cause: something is still reachable that the program no longer actually needs.
| Pattern | What keeps it reachable |
|---|---|
A forgotten setInterval | the timer itself holds a live reference to its callback and everything that callback closes over, forever, until clearInterval |
| A detached DOM node | removed from the page, but still referenced by a JS variable or an event listener you forgot to remove — the node itself, and everything it references, stays alive |
| An unbounded cache | a plain Map used as a cache that only ever grows — every entry is reachable through it forever, since nothing ever calls .delete() |
| A closure over something huge | a small, long-lived closure that happens to reference one variable from a scope containing something large — the ENTIRE execution context stays alive to keep that one binding around |
// The fix for the cache row above — cap it, or use a WeakMap when the
// key's natural lifetime should decide the entry's lifetime (I2 covered this)
const cache = new Map();
function memoizedButBounded(key, compute) {
if (cache.has(key)) return cache.get(key);
if (cache.size >= 500) cache.delete(cache.keys().next().value); // evict oldest
const value = compute();
cache.set(key, value);
return value;
}
Heap snapshots and allocation timelines
DevTools' Memory panel is how a suspected leak actually gets confirmed rather than guessed at: take a heap snapshot, perform the suspect action several times (open and close a modal, navigate back and forth), take another snapshot, and compare. An object count that keeps climbing across that comparison — for a thing you'd expect to be fully cleaned up — is the leak, and the snapshot's retainer tree shows exactly what's still holding a reference to it. The allocation timeline view is the same idea over time instead of two fixed points — useful for catching steady growth during normal use rather than one specific suspected action.
Hidden classes and inline caches
V8 doesn't store objects as generic key/value hash maps the way this sentence probably makes you picture — it dynamically builds a hidden class (an internal, fixed layout) for every distinct shape of object it sees, and every object with that same shape shares the same hidden class.
function Point(x, y) { this.x = x; this.y = y; }
const a = new Point(1, 2); // x then y — hidden class C0
const b = new Point(3, 4); // x then y — SAME hidden class C0, shares it with a
const c = new Point(5, 6);
c.z = 7; // now c has a DIFFERENT shape — its own hidden class C1
A property access like point.x compiled at a specific
call site gets an inline cache: after the first call, V8
remembers "the object at this call site had hidden class C0, and its
x was at this exact offset" — so the next call with the
same hidden class skips property lookup entirely and reads straight
from that offset.
| Term | Means | Speed |
|---|---|---|
| Monomorphic | a call site has only ever seen one hidden class | fastest — the inline cache is a direct hit every time |
| Polymorphic | a call site has seen a handful (2-4) of different hidden classes | still fast — checks a short list |
| Megamorphic | a call site has seen too many shapes to track | slow — V8 gives up on the inline cache and falls back to a generic lookup |
performance.now()
around a quick loop — in practice, allocation cost, garbage
collection pauses, and JIT warm-up noise routinely swamp the actual
effect at small scale, and a rushed 3-line "benchmark" is exactly how
people ship confidently wrong performance conclusions. The takeaway
isn't "go measure this" — it's the practical rule below, which holds
regardless of what any one quick timing run happens to show.
JIT and deoptimization
V8 starts running everything through Ignition, a fast-starting interpreter — there's no compile pause before your code runs at all. A function called enough times gets handed to TurboFan, the optimizing compiler, which compiles it down to fast machine code under the assumptions it's observed so far — including the hidden classes and argument types it's seen at every call site inside it.
Break one of those assumptions — a function optimized for numbers suddenly gets called with a string, a monomorphic call site starts seeing a new shape — and V8 deoptimizes: throws away the compiled machine code and drops back to the slower interpreter for that function, at least until it can safely re-optimize with the new reality accounted for. A function that gets optimized, called differently, deoptimized, called differently again, and re-optimized in a loop never settles into its fast path at all.
Opens in the editor — write it, run it, and check it against real tests.