How it works
Print the bytecode of a two-line function with node --print-bytecode --print-bytecode-filter=add add.mjs. On Node 24.19 / V8 13.6 (arm64, one machine), add(a, b) { return a + b; } produced:
Parameter count 3
Register count 0
Ldar a1
Add a0, [0]
Return
Ignition is a register machine with an implicit accumulator. Ldar a1 loads argument register a1 (b) into the accumulator. Add a0, [0] adds a0 to the accumulator, so the result is a + b, and leaves the result in the accumulator. Return returns it. The dump shows no local registers (Register count 0), because the accumulator is enough. The trailing [0] is a feedback slot index, and the slot #0 BinaryOp line in the example above is the same slot (my reading of the two outputs, not stated in the docs). "Parameter count 3" is two parameters plus the receiver (my reading; not verified in a source).
The feedback vector is allocated lazily. Node's default left add with only a "closure feedback cell array" after two calls, which is why the example passes --no-lazy-feedback-allocation; the flag exists in node --v8-options and is a debugging convenience, not something to ship.
Edge cases
Lazy compilation is observable. With --print-bytecode-filter=inner, bytecode for inner appears only after the first call:
// lazy.mjs, run: node --print-bytecode --print-bytecode-filter=inner lazy.mjs
function outer() {
console.log('outer runs');
function inner() {
return 42;
}
return inner;
}
const fn = outer();
console.log('before inner call');
fn();
// → outer runs
// → before inner call
// → [generated bytecode for function: inner (0x… <SharedFunctionInfo inner>)]
inner was preparsed when outer was compiled, but it got bytecode only at fn(). The V8 preparser post adds why this is hard: inner functions can reference and outlive outer variables, so the preparser must track variable declarations and references to decide what lives on the stack and what in a heap context.
Two caveats from the sources. Eager compilation hints (parentheses around a function expression) can be ignored with --max-lazy (listed in node --v8-options), and over-eager compilation costs memory. And bytecode flushing is age-based: the V8 Lite post says the age increments on every major GC and resets when the function runs, with a threshold after which the next GC may free the bytecode. The flag is --flush-bytecode, on by default here.
Real-world scenario
To confirm that a hot function tiers up, trace it:
node --trace-opt tier.mjs (a loop calling square(n) 100,000 times)
[marking <JSFunction square> for optimization to MAGLEV, ... reason: hot and stable]
[completed compiling <JSFunction square> (target MAGLEV) - took 0.000, 0.042, 0.000 ms]
[marking <JSFunction square> for optimization to TURBOFAN_JS, ... reason: hot and stable]
[completed optimizing <JSFunction square> (target TURBOFAN_JS)]
In two runs square went to Maglev first and then to TurboFan, with no deoptimization. I abridged addresses and timings. Sparkplug did not appear in the --trace-opt lines for square; --trace-opt-status printed BASELINE for other functions in the same Node. Node 24.19's --v8-options lists --sparkplug and --maglev as default-on, and the Maglev post says Chrome shipped Maglev in M117. The trace labels these compiles ConcurrencyMode::kConcurrent.
Trade-offs
Each tier trades compile time and memory for run time. Starting in Ignition keeps start-up fast and memory low, which was the stated reason for Ignition's design. The cost is that a function's first calls are the slowest, and that optimized code is only as good as the feedback behind it.
TurboFan's own internals are in flux. V8's March 2025 blog says it is leaving "Sea of Nodes" for a control-flow-graph IR named Turboshaft: the whole JavaScript backend of TurboFan already uses Turboshaft, while its JavaScript frontend still builds Sea of Nodes graphs, which the post says Maglev is replacing. Treat any diagram of "TurboFan's pipeline" as dated.