How V8 runs your JavaScript
Questions in this set 8
- 01What happens between source text and running code?
- 02What is a hidden class, and why does object shape matter?
- 03What are inline caches, and what does "monomorphic" mean?
- 04What is deoptimisation, and how would you notice it?
- 05How does the garbage collector work?
- 06How do closures and scopes work in memory?
- 07What runs where: the isolate, the context, and the worker
- 08Which classic optimisation advice is now obsolete?
JavaScript performance advice — "don't change object shapes", "avoid delete", "monomorphic call sites" — is meaningless folklore until you know what the engine is doing. Once you do, you can also tell which advice is obsolete, which matters more than reciting any of it.
What happens between source text and running code?
source → scanner/parser → AST → Ignition (bytecode interpreter) → running
↓ (hot code + type feedback)
TurboFan (optimising compiler) → optimised machine code
↑ (assumption violated)
deoptimise back to bytecodeParsing is split in two: V8 pre-parses function bodies it does not think will run soon — recording only enough to report syntax errors and find variable declarations — and fully parses them lazily on first call. That is why parse cost scales with code that actually runs, not total bundle size, and why IIFE-wrapping was once used to hint eager parsing.
Ignition compiles the AST to compact bytecode and interprets it. Low startup cost, low memory, moderate speed. All code starts here.
TurboFan compiles hot functions to optimised machine code using the type feedback Ignition collected. It inlines calls, unboxes numbers, eliminates bounds checks and hoists loop-invariant work — but every one of those optimisations rests on assumptions about the types it observed.
This is a JIT with speculative optimisation, and the word that matters is speculative: if an assumption is violated at runtime, the engine must bail out.
What are inline caches, and what does "monomorphic" mean?
At each property access site, V8 records which hidden classes it has seen and caches the resolved offset:
function getX(p) { return p.x; } // one access site- Monomorphic — one hidden class seen. The cache holds a direct offset; TurboFan can inline it to a single memory load. Fastest.
- Polymorphic — 2 to 4 shapes. A short linear check. Still fast.
- Megamorphic — more than 4. V8 gives up on the site-local cache and falls back to a global hash lookup. Substantially slower, and it blocks inlining.
That is the mechanism behind "keep call sites monomorphic". The practical version: a generic utility called with many different object shapes (a serialiser, a deep-clone, a generic get(obj, key)) will have megamorphic sites, and that is usually fine and not worth contorting your code for. It matters in a genuinely hot loop, and it is why some libraries generate specialised functions per shape.
The same idea applies to function calls: a call site that always calls the same function can be inlined into the caller; one that receives many different callbacks cannot.
What is deoptimisation, and how would you notice it?
TurboFan compiles under assumptions: "x is always a small integer", "this array is packed", "this object has hidden class C2", "this function is never called with a different arity". When an assumption breaks, the engine performs a deopt — discards the optimised code, reconstructs the interpreter's stack frame at the exact bytecode offset, and resumes in Ignition.
function add(a, b) { return a + b; }
for (let i = 0; i < 1e6; i++) add(i, i); // optimised for SMI + SMI
add("x", "y"); // deopt: TurboFan's assumption is invalidatedA single deopt is cheap. The killer is deopt loops — optimise, deopt, reoptimise, deopt — where the function never runs optimised for long. Causes: type changes in a hot function, reading arguments in ways that force materialisation, with, non-strict eval, and (historically) try/catch and let/const in loops, both of which are fine in modern V8.
You can observe it directly rather than guessing:
node --trace-opt --trace-deopt app.js # what got optimised, what bailed and why
node --prof app.js && node --prof-process isolate-*.log
node --cpu-prof app.js # .cpuprofile loadable in Chrome DevToolsThe honest framing for an interview: do not write code to please the JIT. Write clear code, measure, and reach for this knowledge only when a profile shows a hot function underperforming — at which point knowing about shapes, element kinds and deopts is what lets you fix it in ten minutes instead of guessing for a day.
How does the garbage collector work?
V8's heap is generational, exploiting the observation that most objects die young.
Young generation (the nursery), collected by Scavenger — a semi-space copying collector. The nursery is split in two; allocation is a pointer bump in the active half (which is why allocation itself is nearly free in JS). On collection, live objects are copied to the other half; anything not copied is dead and costs nothing to reclaim. Surviving two scavenges promotes an object to the old generation. Scavenges are frequent and sub-millisecond, and their cost is proportional to surviving objects, not to garbage — so producing lots of short-lived garbage is genuinely cheap.
Old generation, collected by Mark-Compact — mark reachable objects from the roots, sweep the rest, and compact to fight fragmentation. To avoid long pauses this is heavily engineered: incremental marking (interleaved with your code), concurrent marking on background threads, parallel sweeping and compaction, and lazy sweeping. The write barrier — extra bookkeeping on every pointer write during marking — is the price paid.
What follows practically:
- Short-lived allocations are fine. Object pooling is usually a pessimisation in JS, because it moves objects into the old generation where collection is expensive, and it defeats the nursery's design.
- Long-lived, large structures are the cost. A big cache, a growing array of events, a
Mapkeyed by DOM nodes — these live in old space, are traversed by every major GC, and are what makes pauses grow. - A memory leak is a reachability bug, not an allocation bug: forgotten listeners and timers, closures retaining large scopes, detached DOM held by JS, unbounded caches. Use
WeakMap/WeakSetfor metadata keyed by objects, andFinalizationRegistryonly for diagnostics — never for correctness, since finalisers may never run. - Node heap flags matter in containers. V8 sizes the old-space limit from host memory unless told otherwise, so
--max-old-space-sizemust be set below the container limit or the kernel OOM-kills the process before V8 ever runs a full major GC.
How do closures and scopes work in memory?
A closure captures its lexical environment, allocated as a context object on the heap. V8's optimiser prunes variables the closure does not reference — but all closures created in the same scope share one context object. So if any one of them references a large variable, that variable is retained for as long as any of them lives.
function attach(el) {
const huge = new Array(1e6).fill("x");
el.onclick = () => console.log("hi"); // does not reference `huge`…
el.onmouseover = () => console.log(huge.length); // …but this shares the context, so
} // `huge` is retained by BOTH handlersThis is the mechanism behind a whole family of "why is this retained?" heap-snapshot investigations, and it is why the retainer chain in DevTools often points at a closure context rather than at your code.
What runs where: the isolate, the context, and the worker
An isolate is one instance of the V8 engine with its own heap, and it is single-threaded by construction. A context is one global object (a page, an iframe) inside an isolate. This is why:
- Two
Workers (or two Nodeworker_threads) are separate isolates with separate heaps, so objects cannot be shared —postMessagestructured-clones the data, which for a large payload is a real, synchronous copy cost on both sides.SharedArrayBufferand transferable objects exist to avoid it. SharedArrayBufferrequires cross-origin isolation (COOP+COEPheaders) because of Spectre, which is why using it is an infrastructure decision, not just a code one.- The main thread's heap and the worker's are collected independently, so moving work to a worker also moves its GC pressure off the main thread — an underrated benefit for INP.
Which classic optimisation advice is now obsolete?
Worth having ready, because it demonstrates current knowledge rather than repeated folklore:
try/catchdeoptimises — obsolete. TurboFan handles it, and exceptions are cheap when not thrown.let/constin loops are slow — obsolete; per-iteration bindings are optimised.- Cache
array.lengthin the loop condition — unnecessary, the compiler hoists it. forbeatsforEach/map— largely obsolete for anything but extremely hot numeric loops, and the difference is usually swamped by what the callback does.- Concatenate all JS into one bundle — obsolete under HTTP/2, and actively harmful for caching.
- Object pooling to reduce GC — usually counterproductive, as above.
What is still true: shape stability, element kinds, monomorphic hot sites, avoiding delete, not leaking, and — dominating all of them — doing less work and less I/O. The engine will not save you from an O(n²) algorithm or a request waterfall, and in real applications those are the actual problem approximately always.