Part 1 · 3 chapters · ~18 min

V8, In Depth

The engine that runs your JavaScript: the Ignition, Sparkplug, Maglev and TurboFan pipeline, hidden classes and inline caches, deoptimisation, the generational garbage collector and heap layout, heap snapshots and the three-snapshot leak technique, the flags worth knowing, startup snapshots and pointer compression.

4

The compilation pipeline and deoptimisation

V8 is a tiered engine. Code starts cheap (interpreted bytecode) and earns faster tiers by running often, using type feedback recorded while it ran. Optimised code is a bet on that feedback; a deopt is the bet being lost.

code
// watch tiering and deopts
function add(a, b) { return a + b; }
for (let i = 0; i < 1e6; i++) add(i, i);        // monomorphic: small integers
add('x', 'y');                                    // new type: strings
for (let i = 0; i < 1e6; i++) add(i, i);

// node --trace-opt --trace-deopt add.js
// [marking ... add for optimization to MAGLEV / TURBOFAN]
// [bailout (kind: deopt-eager, reason: not a Smi): ... add]
common deopt reasoncause in your codefix
wrong map / insufficient type feedbackobjects of different shapes reach a hot functionconstruct objects one way (classes or factories), same property order
not a Smia value that was a small integer becomes a double or stringkeep numeric fields consistently integers or doubles
out of bounds / holey arrayreading past length, arrays with holespreallocate with values, avoid delete arr[i]
arguments object, try/catch (historically)old patterns; modern TurboFan handles mostrest parameters; measure before refactoring
THE V8 PIPELINE
source to bytecode to baseline code to optimised code, and back again on deopt
warmwarmerhotdeoptsourcetextparserAST (lazy)Ignitionbytecode + feedbackSparkplugbaselineMaglevmid-tierTurboFantop tier
swipe the figure sideways, or tap expand for full screen
1/6
parse
V8 parses source into an AST, but lazily: functions are pre-parsed (checked for syntax) and only fully parsed when first called. Startup cost scales with code that runs, not code that loads.
pre-parse everything, fully parse on first calllazy parsing keeps startup proportional to use
5

Hidden classes, inline caches and megamorphism

code
// same shape: one Map chain, monomorphic access
class Point { constructor(x, y) { this.x = x; this.y = y; } }
const pts = Array.from({ length: 1e6 }, (_, i) => new Point(i, i));

// shape chaos: properties added in different orders and conditionally
function make(i) { const o = {}; if (i % 2) { o.y = i; o.x = i; } else { o.x = i; o.y = i; } if (i % 3 === 0) o.z = 1; return o; }

function sumX(arr) { let s = 0; for (const p of arr) s += p.x; return s; }
// node --allow-natives-syntax: %HaveSameMap(a, b) and %DebugPrint(obj) show the maps

Megamorphism is not a bug in your code, it is a cost. Generic code (serialisers, ORMs, validation libraries) is legitimately megamorphic. Only fix it inside measured hot paths, usually by normalising objects into one class at the boundary.

HIDDEN CLASSES AND INLINE CACHES
objects built the same way share a shape; property access sites cache shapes
+x+ymore shapesMap0{}Map1{x}Map2{x, y}load site p.xinline cachemonomorphic1 shapemegamorphic>4 shapes
swipe the figure sideways, or tap expand for full screen
1/6
empty shape
Every object has a hidden class (V8 calls it a Map) describing its layout: which properties, in which order, at which offsets. new Point() starts with the empty map.
hidden class = layout descriptorV8 calls it a Map
6

Garbage collection, heap layout and finding leaks

code
# GC in practice
node --trace-gc server.js
# [12345:0x...]   1234 ms: Scavenge 18.2 (20.1) -> 6.3 (21.1) MB, 1.1 / 0.0 ms  (average mu = 0.99 ...
# [12345:0x...]   9876 ms: Mark-Compact 412.5 (450.0) -> 390.1 (440.2) MB, 85.3 / 0.0 ms ...

node --max-old-space-size=1536 server.js   # cap old space (MB); set to ~75% of the container limit
node --heapsnapshot-signal=SIGUSR2 server.js   # kill -USR2 <pid> writes a .heapsnapshot
the three-snapshot leak technique
  1. Warm the service up, then take snapshot 1.
  2. Run the suspect workload (say 1,000 requests), force GC, take snapshot 2.
  3. Run the same workload again, take snapshot 3.
  4. In DevTools, view snapshot 3 filtered to objects allocated between 1 and 2 that are still alive in 3. Those are your leak candidates.
  5. Follow the retainers path to the root that keeps them: a module-level Map, a listener never removed, a closure in a timer.
flaguse
--max-old-space-size / --max-semi-space-sizeheap caps; a larger semi-space reduces scavenges for allocation-heavy services
--jitlessinterpreter only (no executable memory); for locked-down environments
--no-opt, --max-lazyexperiments: isolate the effect of optimisation
--expose-gcglobal.gc() for tests and leak hunting, never production logic
--trace-gc, --trace-opt, --trace-deoptsee what the engine is doing

Startup snapshots and code cache: Node boots from a V8 snapshot of its own initialised heap (built at compile time), so the standard library does not re-execute on every start. User-land snapshots (--build-snapshot) and the compile cache (module.enableCompileCache()) apply the same idea to your code. Pointer compression stores heap pointers as 32-bit offsets, cutting memory by up to 40% and capping a single isolate heap at 4 GB; Node's default builds do not enable it, Chrome and Electron do.

THE V8 HEAP AND GARBAGE COLLECTOR
a generational heap: a fast scavenger for young objects, mark-compact for the old
promotenew spacesemi-spacesold spacesurvivorslarge object> ~500 KBcode spaceJIT outputScavengercopying, parallelMark-Compactconcurrent markingmain threadpauses
swipe the figure sideways, or tap expand for full screen
1/6
generational hypothesis
Most objects die young. V8 splits the heap: a small new space (a few MB to tens of MB) for fresh allocations, and an old space for objects that survive.
most objects die youngso collect the young space often and cheaply