How V8 Works Under the Hood
One call with the wrong argument type, made once, makes the next 100 million integer additions roughly 3x slower. Hidden classes, inline caches, the tier-up thresholds and every deopt reason — checked against what Node 22 actually does, not what the blog posts say.
How V8 Works Under the Hood
Call add 100 million times with two integers. Then run the identical loop in a fresh process, but slip in one call with strings first:
// b1.js
function add(x, y) { return x + y; }
const N = 100_000_000;
if (process.argv[2] === 'pollute') { for (let i = 0; i < 1e6; i++) add(i, i + 1); add('a', 'b'); }
let s = 0;
const t = process.hrtime.bigint();
for (let i = 0; i < N; i++) s += add(i, i + 1);
console.log(process.argv[2] || 'clean', (Number(process.hrtime.bigint() - t) / 1e6).toFixed(0) + 'ms');$ node -v && for i in 1 2 3; do node b1.js; done && for i in 1 2 3; do node b1.js pollute; done
v22.22.3
clean 104ms
clean 106ms
clean 183ms
pollute 355ms
pollute 355ms
pollute 355msSame function, same 100 million integer additions, roughly 3x slower. (Node 22.22.3, aarch64 Linux, one run per process. Your absolute numbers will differ — the machine matters and so does what else is running on it. The ratio is what reproduces: I re-ran this interleaved on a loaded box and got 280–670 ms clean against 1050–1320 ms polluted.)
V8 had compiled add to machine code that assumed both arguments were always small integers. The string call broke that assumption. V8 threw the compiled code away and recompiled against wider feedback. That's deoptimization — and understanding it requires understanding how V8 bets on your code's behavior before it runs.
The Mental Model
V8 is not a simple interpreter. It's a multi-tiered compilation pipeline where JavaScript moves through several representations, each more expensive to produce but faster to execute.
Most code never reaches TurboFan. The optimizer only fires for functions called often enough to justify the compilation cost. Everything else runs in Ignition, possibly via Sparkplug.
The Compilation Pipeline
Parsing
V8's parser is a hand-written recursive-descent parser that produces an AST from source text. It operates in two modes:
- Eager parsing: top-level code that executes immediately. Full parse, full AST.
- Lazy parsing: function bodies are pre-parsed (syntax validated only) and not fully parsed until the function is first called.
Lazy parsing is why a server that imports a large module doesn't pay the full parsing cost upfront. The tradeoff: the first call to any lazily-parsed function carries a hidden parse cost, which shows up in profiles of startup-only code.
Ignition
Ignition is a register-based bytecode interpreter. The AST is lowered to a compact bytecode representation, which Ignition then executes.
You do not have to take my word for what it emits. Put this in bc.js:
function multiply(a, b) {
return a * b;
}
multiply(2, 3);and ask V8 to print it:
$ node --print-bytecode --print-bytecode-filter=multiply bc.js
[generated bytecode for function: multiply (0x25be633db551 <SharedFunctionInfo multiply>)]
Bytecode length: 6
Parameter count 3
Register count 0
Frame size 0
28 S> 0x3f0d09481e88 @ 0 : 0b 04 Ldar a1
37 E> 0x3f0d09481e8a @ 2 : 3d 03 00 Mul a0, [0]
41 S> 0x3f0d09481e8d @ 5 : ae ReturnSix bytes, zero registers. Ldar a1 loads the second argument into the accumulator, Mul a0, [0] multiplies it by the first argument, Return returns the accumulator. Parameters live in dedicated argument slots, so there is nothing to spill. The [0] on Mul is the interesting part: it is a feedback-vector slot index.
While executing, Ignition populates that feedback vector — a per-function array with a slot per feedback-consuming operation in the bytecode. Each slot records what V8 actually observed at runtime: were the operands small integers? floats? mixed? What was the hidden class of obj when obj.x was accessed? The feedback vector is the critical input to everything that comes after. V8 does not even allocate one until a function has been called a few times (--invocation-count-for-feedback-allocation defaults to 8).
Sparkplug
Sparkplug sits above Ignition in the tier stack. It walks Ignition bytecode in a single linear pass and emits machine code as it goes, building no intermediate representation and doing no optimization. The output runs faster than Ignition because interpreter dispatch overhead is gone, but the code quality is roughly equivalent — Sparkplug is about getting a function off the interpreter fast, not about generating efficient code. V8's own launch post for it reports "around 5–15%" improvement in V8 main-thread time on their browsing benchmarks (v8.dev/blog/sparkplug).
Maglev
V8 12.4 — the version in Node 22 — also contains Maglev, a mid-tier optimizing JIT that sits between Sparkplug and TurboFan: it uses feedback, but compiles a CFG rather than TurboFan's graph, so it's much cheaper. You will not see it in Node 22 unless you ask for it:
$ node --v8-options | grep -A1 "^ --maglev "
--maglev (enable the maglev optimizing compiler)
type: bool default: --no-maglevSo on Node 22 the real pipeline is Ignition → Sparkplug → TurboFan. If you read a V8 blog post describing a four-tier pipeline, that's Chrome, not your server.
TurboFan
TurboFan is V8's top-tier optimizing compiler. Compilation happens on a background thread while the lower tier keeps running; when it finishes, the function's entry point is patched to the new machine code.
The tier-up threshold is not "a few hundred calls" — in this V8 it's three thousand, and you can watch it happen. %GetOptimizationStatus returns a bitmask of the OptimizationStatus flags in src/runtime/runtime.h; polling it every iteration on the add from the intro puts the tier-up marks on the same call every run:
call state
1 isFunction+interpreted
1506 isFunction+baseline
3010 isFunction+markedForConcurrentOptimization+baseline
3011 isFunction+optimizingConcurrently+baseline
3259 isFunction+optimized+turboFannedNote the middle row: by call 1506 the function is already off the interpreter and running Sparkplug (kBaseline), which is why the TurboFan request at 3010 comes from baseline code rather than from Ignition. Only the last row moves between runs — 3010 and 3011 were identical across every run, while the call at which the finished TurboFan code actually got installed ranged from about 3050 to 3260, because compilation is happening on a background thread.
That 3010 is not a coincidence: --invocation-count-for-turbofan defaults to 3000. The budget is spent per bytecode-byte, so a large function tiers up after fewer calls than a six-byte one, and loops can tier up early via on-stack replacement (--invocation-count-for-osr=800).
Half the write-ups you'll find say TurboFan's IR is a Sea of Nodes — a graph where control flow and data flow are both edges between operation nodes. That's now only half true. V8 has been replacing it with Turboshaft, a conventional CFG-based IR, and per V8's own writeup "the whole JavaScript backend of Turbofan uses Turboshaft instead", leaving Sea of Nodes in only two places — the builtin pipeline and the frontend of the JavaScript pipeline (v8.dev/blog/leaving-the-sea-of-nodes). Turboshaft is on by default in Node 22 (--turboshaft). Either way you get the aggressive stuff: inlining, load and store elimination, escape analysis (a non-escaping object's fields get replaced with plain values so the allocation disappears — not moved to the stack, removed), loop peeling and rotation. Every one is a --turbo-* flag you can toggle off to see what it was buying you.
TurboFan reads the feedback vector and compiles under the assumption that observed behavior continues, emitting deoptimization guards — runtime checks for each assumption. If multiply only ever saw small integers, that's a Smi check on both arguments and then a native integer multiply. No boxing, no coercion, no dispatch.
If the check fails, execution deoptimizes.
Hidden Classes: Eliminating Property Lookup Cost
JavaScript objects are dictionaries: you can add, remove or retype properties at any time. A naïve implementation stores them in a hash map — hash the key string, resolve collisions, follow pointers — on every single access. V8 replaces that with hidden classes (Maps, internally).
When you create an object, V8 attaches an initial hidden class describing its current layout: which properties it has, their types, and which slot each one occupies. Every time you add a new property, the object transitions to a new hidden class. %DebugPrint shows the map and the slot assignment:
const p = {}; // map: JS_OBJECT_TYPE, no own properties
p.x = 1; // #x: 1 (const data field 0), location: in-object
p.y = 2; // #x: (field 0), #y: (field 1), both in-objectThe transitions are cached, so two objects that receive properties in the same order end up on the same map object, not merely an equivalent one. %HaveSameMap proves it:
const a = {}; a.x = 1; a.y = 2;
const b = {}; b.y = 2; b.x = 1; // same names, different order
const c = {}; c.x = 1; c.y = 2;
%HaveSameMap(a, b); // false
%HaveSameMap(a, c); // trueRun that with node --allow-natives-syntax. Order matters: b walked a different transition chain and landed on a different hidden class, even though from JavaScript's perspective a and b look identical. Once instances share a map, reading a.y is a load at a fixed offset from the object pointer — no hashing, no string comparison, no pointer chasing.
Inline Caching
Hidden classes pay off at call sites through Inline Caching (IC). Every property access operation in bytecode has an IC slot that records observed hidden classes and where the property lives on each.
Uninitialized → Monomorphic → Polymorphic → Megamorphic- Monomorphic: one hidden class seen. A single map check and a fixed-offset load. The fastest possible property read.
- Polymorphic (2–4 maps): a chain of map checks. Still fast.
- Megamorphic (5 or more maps): the IC gives up on per-site caching and probes V8's global megamorphic stub cache, a hash table keyed on map and name.
The 4 is a real constant, not folklore: --max-valid-polymorphic-map-count defaults to 4. And you can watch a single call site walk the whole ladder. Feed one o.x load six objects with six different shapes and log the IC transitions:
$ node --log-ic --logfile=ic.log --no-logfile-per-isolate ic.js
$ grep '^LoadIC' ic.log | grep ',x,'
LoadIC,...,0,1,0x2649c73051b9,x,, # uninitialized -> monomorphic
LoadIC,...,1,P,0x2649c7305261,x,, # monomorphic -> polymorphic
LoadIC,...,P,P,0x2649c7305349,x,,
LoadIC,...,P,P,0x2649c7305431,x,,
LoadIC,...,P,N,0x2649c7305519,x,, # 5th map: -> megamorphic
LoadIC,...,N,N,0x2649c7305601,x,,The letters come from IC::TransitionMarkFromState in src/ic/ic.cc: 0 uninitialized, 1 monomorphic, P polymorphic, N megamorphic. (The same switch has four more marks the ladder skips — X for NO_FEEDBACK, ^ for RECOMPUTE_HANDLER, D for MEGADOM and G for GENERIC.) Note that it's the fifth distinct map that tips the site over, not the fifth object.
This is why library code that accepts arbitrary objects is structurally slower than domain-specific code that always receives objects of the same shape. It's not a language limitation — it's a consequence of how inline caches degrade.
delete obj.prop demotes an object to dictionary mode — a real hash map with no hidden class, no IC benefits, and none of TurboFan's fast paths. On Node 22 this happens even when you delete the most recently added property: %HasFastProperties(obj) returns true before the delete and false after, either way. If you need to clear a property, assign null or undefined instead.
Garbage Collection: Orinoco
V8's garbage collector is Orinoco — a generational collector built around three orthogonal techniques for reducing main-thread pauses:
| Technique | Meaning |
|---|---|
| Incremental | GC work is interleaved with JS execution in small steps |
| Concurrent | Marking and sweeping run on background threads while JS runs |
| Parallel | Unavoidable pauses are shared across multiple helper threads |
Heap Layout
Don't guess at this — Node will list the spaces for you:
$ node -e "console.log(require('v8').getHeapSpaceStatistics().map(s=>s.space_name).join('\n'))"
read_only_space
new_space
old_space
code_space
shared_space
trusted_space
new_large_object_space
large_object_space
code_large_object_space
shared_large_object_space
trusted_large_object_spaceTwo things older write-ups (and older versions of my own mental model) get wrong. There is no map space — V8 folded hidden-class objects back into old space, and on 12.4 that space doesn't exist. And "large object" starts far lower than people assume: the cutoff is 128 KB, not half a megabyte. Watching large_object_space while allocating FixedArrays of increasing size brackets it exactly:
new Array(16380) bytes=131056 large_object_space delta 0
new Array(16384) bytes=131088 large_object_space delta 131088New space is two semi-spaces, and its default ceiling on this build is 16 MB each — so the young generation grows to 32 MB, not 16. space_size reports both halves, and it tracks --max-semi-space-size at exactly 2x:
// ns.js — churn small objects and record the high-water mark
const v8 = require('v8'); let max = 0; const keep = [];
for (let i = 0; i < 6e6; i++) {
keep.push({ a: i, b: 'x' + i }); if (i % 500 === 0) keep.length = 0;
if (i % 50000 === 0) {
const s = v8.getHeapSpaceStatistics().find(x => x.space_name === 'new_space');
if (s.space_size > max) max = s.space_size;
}
}
console.log('max new_space space_size', (max / 1048576).toFixed(1), 'MB');$ node ns.js
max new_space space_size 32.0 MB
$ node --max-semi-space-size=4 ns.js
max new_space space_size 8.0 MBGrowth is adaptive, so a shorter run tops out lower — 2 million iterations only reaches 16 MB. The 32 MB is the ceiling, not the starting point.
Within the young generation, V8 tracks two sub-generations rather than two named spaces: objects are allocated into the nursery, and survivors of one collection become intermediate. Survive a second and you're promoted to old space (v8.dev/blog/trash-talk).
All allocations start in the nursery using bump-pointer allocation — a pointer increment, nothing more. No free-list, no fragmentation handling. Just fast.
Minor GC — The Scavenger
The Nursery fills quickly (it's small by design). When full, the Scavenger runs. It's a semi-space copying collector.
In Orinoco, the Scavenger runs in parallel across multiple helper threads. The critical insight is that in a young generation, most objects are already dead by the time collection runs — short-lived closures, request objects, intermediary values. The Scavenger only copies survivors, which is a small fraction of the nursery, so the pause scales with what lives, not with what you allocated. On a loop allocating small objects that mostly die immediately, every scavenge --trace-gc reports comes in comfortably under a millisecond:
$ node --trace-gc -e "const keep=[];for(let i=0;i<4e6;i++){const o={a:i,b:'s'+i};if(i%2000===0)keep.length=0;keep.push(o);}"
... 16 ms: Scavenge 4.4 (6.1) -> 3.8 (6.4) MB, pooled: 0 MB, 0.34 / 0.00 ms ...
... 21 ms: Scavenge 8.0 (12.9) -> 4.6 (12.9) MB, pooled: 0 MB, 0.29 / 0.00 ms ...
... 28 ms: Scavenge 8.0 (12.9) -> 4.6 (20.9) MB, pooled: 0 MB, 0.62 / 0.00 ms ...Hold onto survivors instead and those numbers climb. Objects that survive a second scavenge are promoted to old space. They're no longer cheap to collect.
Major GC — Mark-Sweep-Compact
Old Space is collected via Mark-Sweep-Compact. This is expensive. Orinoco's contribution is making most of it concurrent.
Marking: from the roots (stack frames, global references), traverse the object graph and mark what's reachable. Orinoco does this concurrently on background threads while JS runs; write barriers record mutations that happen mid-marking, and the main thread only needs a short final pause to drain that worklist.
Sweeping: walk the pages and return dead memory to free-lists. Also concurrent.
Compaction: the old generation fragments over time — gaps between live objects too small to hold a real allocation. V8 evacuates the most fragmented pages in parallel, copying live objects out and releasing the page.
Idle-Time GC
V8 exposes an idle-notification API so an embedder can hand it spare time. Chrome uses the gaps between frames for incremental GC tasks — a page of sweeping, a batch of compaction — during time that would be wasted anyway.
Node is not that embedder. --trace-idle-notification prints a line per idle notification, and a Node process that allocates hard and then sits doing nothing for three seconds prints nothing at all. If you assumed GC work gets quietly absorbed while your event loop is quiet: it doesn't. It's all paid on the allocating thread, during allocation.
v8.getHeapStatistics() does not have heap_used or heap_total keys — reach for those and you get undefined. The real names are used_heap_size, total_heap_size, total_heap_size_executable, total_physical_size, total_available_size, heap_size_limit, malloced_memory, peak_malloced_memory, external_memory, and a few counters. total_heap_size_executable tells you how much memory is holding compiled machine code; if it's unexpectedly large, you have a lot of hot functions or you're generating code dynamically.
Deoptimization
TurboFan compiles under assumptions. Every assumption has a runtime guard. When a guard fails, V8 deoptimizes: it discards the machine code, reconstructs the equivalent Ignition interpreter frame (using a deoptimization table baked into the compiled code), and resumes execution there.
V8 12.4 defines exactly 61 named deoptimization reasons — DEOPTIMIZE_REASON_LIST in src/deoptimizer/deoptimize-reason.h, one macro line each. A few you will actually see:
| Reason | Trace string | What caused it |
|---|---|---|
NotASmi | not a Smi | Expected a small integer, got a float, string or object |
WrongMap | wrong map | Object's hidden class isn't the one compiled for |
OutOfBounds | out of bounds | Index outside the compiled bounds assumption |
InsufficientTypeFeedbackForBinaryOperation | Insufficient type feedback for binary operation | No feedback for an operator at compile time |
Overflow | overflow | An integer result stopped fitting in a Smi |
Note the shape of that fourth one. There is no bare InsufficientTypeFeedback; V8 has twelve separate InsufficientTypeFeedbackFor… reasons — call, construct, for-in, binary op, compare op, generic named/global/keyed access, unary op, array literal, object literal, instanceof — because knowing which operation had no feedback is the whole point.
After a deoptimization, execution resumes in the interpreter, collects fresh feedback, and TurboFan may recompile with broader assumptions. A function that keeps deoptimizing for the same reason eventually gets marked non-optimizable and left in the interpreter.
Point the tracer at the intro benchmark and the story is right there:
$ node --trace-deopt deopt.js
[bailout (kind: deopt-eager, reason: not a Smi): begin. deoptimizing
<JSFunction add (sfi = 0x154cf621b6c1)>, <Code TURBOFAN>, opt id 0,
bytecode offset 2, deopt exit 0, FP to SP delta 32, ...]--trace-opt is the other half: it logs every tier-up decision with the target tier, the concurrency mode, the reason (hot and stable) and how long compilation took.
Practical Implications
Object shapes are a runtime contract. Your constructor defines a layout contract with V8. All instances must receive the same properties in the same order. If some instances receive an optional property and others don't, they end up with different hidden classes.
// Breaks monomorphism
class EventOptional {
constructor(type, payload) {
this.type = type;
if (payload) this.payload = payload; // some instances have it, some don't
}
}
// Keeps all instances on the same map
class EventAlwaysSet {
constructor(type, payload) {
this.type = type;
this.payload = payload ?? null; // always set, even if null
}
}
// node --allow-natives-syntax
%HaveSameMap(new EventOptional('x'), new EventOptional('x', {})); // false
%HaveSameMap(new EventAlwaysSet('x'), new EventAlwaysSet('x', {})); // trueHot functions should receive one type. A function called with integers in a tight loop and occasionally with floats forces V8 to widen its numeric representation. Keep type inputs to hot functions consistent.
Array holes are a representation change. Sparse arrays use a slower holey elements representation; dense, sequentially filled arrays use a fast packed one. The transition is one-way — and "one-way" is literal, not approximate. Filling the hole back in does not undo it:
const dense = [];
for (let i = 0; i < 1000; i++) dense.push(i);
// %DebugPrint -> Map(PACKED_SMI_ELEMENTS)
const sparse = new Array(1000);
sparse[999] = 1;
// %DebugPrint -> Map(HOLEY_SMI_ELEMENTS)
const h = [1, 2, 3]; // PACKED_SMI_ELEMENTS
delete h[1]; // HOLEY_SMI_ELEMENTS
h[1] = 2; // still HOLEY_SMI_ELEMENTS — no way backAllocation rate matters as much as total size. Short-lived objects that die in the nursery are cheap — the scavenger barely touches them. The expensive pattern is objects that survive one or two scavenges, pay the promotion cost, and then die: they inflate the old generation and buy you major GC cycles.
The Bet
V8 bets on your code's types being stable, on the object shapes your hot functions receive matching what it compiled for, on your integers staying integers. The intro benchmark is what losing that bet costs: 104 ms becomes 355 ms because of one call with the wrong argument type, made once, a million calls earlier.
The code that breaks the bets is predictable, and none of it is forbidden — it just runs slower. So when a hot path is slower than it should be, work the list in order, because every item on it is observable rather than guessable:
node --trace-opt --trace-deopt— is the function even reaching TurboFan, and what reason keeps kicking it out?node --allow-natives-syntax+%HaveSameMap— do the objects your hot function receives actually share a map?node --log-ic— did that property access go megamorphic, and on which fifth shape?%HasFastProperties— did adeletesomewhere put the object in dictionary mode?node --trace-gc— are your scavenges 0.3 ms or 30 ms, and is the old generation growing between them?
Nothing on that list requires reading V8's source. It just requires not trusting your model of what the engine is doing.
Comments (0)
No comments yet. Be the first to share your thoughts!