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.
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.
// 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 reason | cause in your code | fix |
|---|---|---|
| wrong map / insufficient type feedback | objects of different shapes reach a hot function | construct objects one way (classes or factories), same property order |
| not a Smi | a value that was a small integer becomes a double or string | keep numeric fields consistently integers or doubles |
| out of bounds / holey array | reading past length, arrays with holes | preallocate with values, avoid delete arr[i] |
| arguments object, try/catch (historically) | old patterns; modern TurboFan handles most | rest parameters; measure before refactoring |
Hidden classes, inline caches and megamorphism
// 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 mapsMegamorphism 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.
Garbage collection, heap layout and finding leaks
# 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
- Warm the service up, then take snapshot 1.
- Run the suspect workload (say 1,000 requests), force GC, take snapshot 2.
- Run the same workload again, take snapshot 3.
- In DevTools, view snapshot 3 filtered to objects allocated between 1 and 2 that are still alive in 3. Those are your leak candidates.
- Follow the retainers path to the root that keeps them: a module-level Map, a listener never removed, a closure in a timer.
| flag | use |
|---|---|
--max-old-space-size / --max-semi-space-size | heap caps; a larger semi-space reduces scavenges for allocation-heavy services |
--jitless | interpreter only (no executable memory); for locked-down environments |
--no-opt, --max-lazy | experiments: isolate the effect of optimisation |
--expose-gc | global.gc() for tests and leak hunting, never production logic |
--trace-gc, --trace-opt, --trace-deopt | see 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.