A1 · Part 3 · Advanced
Engine & memory
What V8 is actually doing while your code just runs.
06On this page
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
call stack — fixed-size frames, LIFO
main()x = 5
makePoint()x = 1, y = 2
a frame's local primitives live right here
heap — dynamic, garbage-collected
Point { x: 1, y: 2 }0x7a2f…
the stack only ever holds a REFERENCE to this
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.
⚠ The real engine is smarter than this diagram
V8 actually runs escape analysis — if it can prove an object
never leaves the function that creates it, it may stack-allocate that
object anyway, and a captured primitive can get promoted onto the
heap as part of a closure's context. The stack/heap split above is
the correct mental model for reasoning about your code; the engine's
actual placement decisions are an optimization detail on top of it,
not a contradiction of it.
Each function call gets its own execution context — the box the execution context chapter took apart at the beginner level. Seen from the engine, it bundles an environment record (the actual variable bindings) with a reference to the outer environment, and that chain of outer references is the scope chain. A closure, mechanically, is just a function holding onto an environment that would otherwise have been discarded when the call's stack frame popped.
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.
Dry run: what's reachable, step by step
| Step | Code | a reachable? | b reachable? |
|---|---|---|---|
| 1 | const a = {}; | yes — local var on makeCycle's stack frame | doesn't exist yet |
| 2 | const b = {}; | yes | yes — local var on the same stack frame |
| 3 | a.friend = b; | yes | yes — now also pointed to by a.friend |
| 4 | b.friend = a; | yes — now also pointed to by b.friend | yes |
| 5 | makeCycle() returns, its stack frame pops | no — no root points to a anymore; only b.friend does | no — only a.friend points to it |
| 6 | next GC pass | collected | collected |
Reachability, not reference count, decides this — a and b
still reference each other right up through step 6, but neither is reachable
from a root once the frame that held them pops, so the collector reclaims the
whole two-object cycle in one pass.
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 created in a scope that also holds something large — V8 keeps only the variables some closure in that scope uses, but every closure created there shares one context object, so if any sibling closure mentions the large value, it lives as long as the small closure does |
// 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;
}
Rule Every leak is a lifetime mismatch: some
reference is living longer than the data it points to should. Fixing
a leak is almost always "stop something from holding a reference it
no longer needs" — clear the interval, remove the listener, cap the
cache, null out the field — never a special "free this memory" call,
because JS has no such call.
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 |
⚠ This is genuinely hard to see with a stopwatch
It's tempting to prove this with
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.
Rule Build objects of the same "kind" with
their properties assigned in the same order, every time —
ideally all in the constructor, none bolted on conditionally
afterward. Shape consistency is what keeps a hot call site
monomorphic; it's a real, well-documented V8 optimization concern,
not premature optimization folklore.
JIT and deoptimization
V8 starts running everything through Ignition, a fast-starting interpreter — there's no compile pause before your code runs at all. Code that keeps running climbs a ladder of compilers, each slower to compile and faster to run: Sparkplug turns bytecode into machine code almost instantly without optimising it, Maglev does a quick optimising pass using the types it has seen, and the hottest functions reach TurboFan, the top-tier optimizing compiler, which compiles them 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.
Say it like this → "Predictable shapes and
stable argument types aren't just a style preference — they're what
let TurboFan's assumptions hold, which is what keeps a hot function
compiled instead of bouncing back to the interpreter every time
something unexpected shows up at one of its call sites."
Interview answer, one sentence
"V8 keeps primitives and call frames on the stack and objects on the heap, and its garbage collector frees anything unreachable, so a JavaScript leak is almost always something still reachable that you forgot — a listener, a timer, a detached DOM node — and you find it by comparing heap snapshots."
Before you move on
- Explain why
aandbinmakeCyclebecome eligible for collection even though they still reference each other, in terms of reachability from a root. - Explain the generational hypothesis behind V8's young/old generation split, and why it makes the Scavenger fast.
- Explain what makes a call site monomorphic, polymorphic, or megamorphic, and why building objects with properties in a consistent order matters for it.
- Explain what causes V8 to deoptimize a function, and why a function called with alternating argument types might never settle into TurboFan's fast path.
- Given a suspected leak — a forgotten interval, a detached DOM node, an unbounded cache — name the fix, and how a heap snapshot comparison would confirm it.
Opens in the editor: write it, run it, and check it against real tests.
All 31 for this level →Finished reading?
0 of 41 chapters read in this series