Viscosity
Review
Part V · Inside real JavaScript engines · Day 16

61. The V8 pipeline

Your Drip VM runs a function the same way on its first call and its millionth. V8 doesn’t: the same function can run in four different ways in one program, and you can watch it switch.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain why an engine starts in an interpreter and compiles only the code that turns out to be hot.
  • Name V8’s four tiers, Ignition, Sparkplug, Maglev and TurboFan, and say what each one is for.
  • Read a few lines of real Ignition bytecode and compare them with Drip’s.
  • Watch a real function change tiers in Node, and explain why it can also move back down.

The same function, four ways

Part IV ended with a VM that compiles a Drip program to bytecode once and then runs it in a loop. Every function gets the same treatment, whether it runs once or a million times.

Here is a tiny JavaScript program that asks V8 a question after every call: which kind of code is running add right now? The % functions are V8 internals that Node unlocks with a flag.

// node --allow-natives-syntax tiers.js
function add(a, b) { return a + b; }
function tier(f) {
  if (%ActiveTierIsTurbofan(f))
    return 'TurboFan';
  if (%ActiveTierIsMaglev(f))
    return 'Maglev';
  if (%ActiveTierIsSparkplug(f))
    return 'Sparkplug';
  return 'Ignition';
}
let last = '', s = 0;
for (let i = 1; i <= 1e5; i++) {
  s = add(s, i);
  const t = tier(add);
  if (t !== last) console.log(i, t);
  last = t;
}

It prints a line only when the answer changes. One real run:

Node v22.23.4, V8 12.4.254.21
1 Ignition
6256 Sparkplug
15298 TurboFan
65537 Sparkplug
75770 TurboFan

The same add ran three different ways, and at call 65,537 it went back. A second run switched at different call counts (7,342 and 10,895), because some of this work happens on background threads. By the end of the chapter you’ll be able to explain every line.

Start cheap, spend on what’s hot

Intuition

A new restaurant doesn’t print a glossy menu on day one. It writes the dishes on a chalkboard, which is quick and easy to change. Once a dish keeps selling, it earns a proper place on the printed menu. Effort goes where the demand turned out to be.

Most of a web page’s JavaScript runs a few times, or never. Compiling all of it to fast machine code before running would waste time and memory on code that doesn’t matter. So V8 starts every function in the cheapest form that can run, and spends more effort only on functions that prove to be hot. Step through add’s life:

Step throughA function climbing V8’s tiers
Four tiers stacked from Ignition at the bottom to TurboFan at the top. A marker shows which tier is running add, a heat bar shows how hot it is, and a box shows whether it has a feedback vector.TIERHEATTurboFanoptimizing, slow to compileno codeMaglevoptimizing, quick to compileno codeSparkplugbytecode → machine codeno codeIgnitioninterprets bytecodeno codehotcoldsource textfeedback vector: not yet
The script arrives as text. Before add can run at all, V8 parses it and compiles it to bytecode, as Drip’s compiler did. There is no machine code yet.
1/8
Definition

Tiering is running each function in one of several tiers (execution modes) and moving it to a faster but costlier tier as it gets hot. A JIT compiler (just-in-time compiler) compiles code while the program runs, right before it is needed, rather than ahead of time. The V8 team described its pipeline this way in 2016: code starts in a baseline form, and is re-compiled by an optimizing compiler when it proves hot[1].

How does V8 decide that a function is hot? In the V8 inside Node 22, each function gets a budget that its bytecode uses up as it runs. The budget for TurboFan is the function’s bytecode length times a count, 3000 by default, and Maglev’s works the same way with 400. The interpreter spends the budget on every return and every backward jump of a loop, by the size of the bytecode it ran through[5]. So hotness measures work done, not calls: a function with a busy loop inside gets hot after far fewer calls. These numbers are tuning knobs, not laws: treat them as a snapshot of one version.

Ignition: the interpreter you already know

The bottom tier is Ignition, V8’s interpreter. V8 used to start with a quick compiler to machine code instead. Ignition, announced in 2016, replaced it to save memory: its bytecode is a quarter to a half the size of the old baseline machine code[1]. Since V8 5.9 (Chrome 59, 2017), every JavaScript function starts in Ignition[2].

You can print Ignition’s bytecode. Here is add next to the same function in Drip’s VM:

// node --print-bytecode
//   --print-bytecode-filter=add
// (addresses removed, spacing squeezed)
Bytecode length: 6
@ 0 : 0b 04      Ldar a1
@ 2 : 3b 03 00   Add a0, [0]
@ 5 : ae         Return
== add ==            (Drip VM)
0000    1 OP_GET_LOCAL        1
0002    | OP_GET_LOCAL        2
0004    | OP_ADD
0005    | OP_RETURN
0006    | OP_NIL
0007    | OP_RETURN

Drip is a stack machine: it pushes a, pushes b, and OP_ADD pops both. Ignition is a register machine with a special register, the accumulator, that most instructions read and write without naming it[1]. Ldar a1 loads the second parameter (b) into the accumulator. Add a0, [0] adds register a0 (a) to it[6]. Return returns whatever is in the accumulator. Three instructions instead of four (Drip’s last two are the implicit return nil its compiler always adds).

Look at the [0] in Add a0, [0]. It isn’t a value. It’s the number of a feedback slot: as it adds, the handler records what kinds of values it saw[6]. Drip’s VM never did this. It’s the single most important difference, and it’s what lets the upper tiers exist.

Definition

Ignition is V8’s bytecode interpreter, the tier every function starts in. It is a register machine with an implicit accumulator. Its instructions carry feedback slot numbers, where it records what each operation saw while running.

Three compilers above it

An interpreter keeps a cost it can’t remove: decoding each instruction and dispatching to the next one. The V8 team’s 2021 post on Sparkplug puts it plainly: dispatching depends on the CPU’s branch prediction, and decoding reads operands from memory every time[3]. You met that cost in your own VM’s dispatch loop. The tiers above Ignition each spend more compile time to remove more of it.

  • Sparkplug (V8 9.1, 2021) compiles bytecode straight to machine code in a single pass, with no IR of its own. Its code mostly calls the same helpers the interpreter uses, and keeps the same stack-frame layout, so V8 can switch between them almost for free. The V8 team calls it non-optimizing[3].
  • Maglev (desktop Chrome M117, described in 2023) is a quick optimizing compiler with an SSA IR. In 2023 the team reported that it compiled about 10 times slower than Sparkplug and about 10 times faster than TurboFan[4].
  • TurboFan is the top tier, aimed at peak speed. Since 2017 it compiles from Ignition’s bytecode, not by re-parsing the source as its predecessor Crankshaft did[2].

The two optimizing tiers read the feedback and make speculative code: code that assumes what it saw will keep happening, plus a check that bails out if it doesn’t[4].

Definition

V8’s optimizing tiers are the compilers above Ignition: Sparkplug (fast, one pass, no optimizations, removes interpreter overhead), Maglev (fast optimizing compiler) and TurboFan (slow, best code). Maglev and TurboFan specialize code using the recorded feedback.

Reading the run again

Now the output makes sense. add started in Ignition. Around six thousand calls in, it was warm enough for Sparkplug, and soon after TurboFan. Why no Maglev? Ask the build itself: node --v8-options lists Maglev as default: --no-maglev in this Node 22 build, while Sparkplug and TurboFan are on[5].

And call 65,537? Rerunning with --trace-deopt shows the reason, cut down here to fit:

Node v22.23.4, V8 12.4.254.21
[bailout (kind: deopt-eager,
  reason: not a Smi): ...
  deoptimizing ... <JSFunction add ...>

The running sum passed 231 − 1, the largest Smi in this build (chapter 22). TurboFan’s code had assumed small integers, because that’s all the feedback had ever seen. The guess failed, the optimized code was dropped, and add carried on in a lower tier until it got hot again. Chapter 66 takes that apart.

Try itWarm-up you can time
function add(a, b) { return a + b; }
let s = 0;
for (let r = 0; r < 5; r++) {
  const t = performance.now();
  for (let i = 0; i < 2e5; i++) {
    s = add(s, 1);
  }
  const ms = performance.now() - t;
  console.log('round', r,
    ms.toFixed(2), 'ms');
}
// Your numbers are your device's.
// Often the first round is slowest.
Under the hood

The tiers keep changing. Before 2017 V8 ran Full-codegen and Crankshaft, used since 2010[2]; Sparkplug arrived in 2021[3] and Maglev in 2023[4]. Even TurboFan’s insides moved: in 2025 the team wrote that its JavaScript backend had replaced its “Sea of Nodes” IR with a control-flow-graph IR called Turboshaft[7]. Viscosity doesn’t compete with any of this. It rewrites your source before V8 ever sees it, so its output still goes through the same tiers.

Your turn

ExerciseA two-tier function

Write tierUp(slow, compile, n). It returns a function run that behaves exactly like slow. For the first n calls, run calls slow. Right after the n-th call, it calls compile(slow) once, keeps the result, and uses that faster function for every later call.

function tierUp(slow, compile, n) {
  return (x) => slow(x);
}
Extra practiceHot by budget, not by calls

In the V8 inside Node 22, a function’s TurboFan budget is count * len, where len is its bytecode length, and running bytecode uses the budget up. Write hotAt(len, count, ran). ran[i] is how many bytecodes call i + 1 executed. Return the (1-based) number of the call that uses up the budget (total ≥ budget), or -1 if it never does.

function hotAt(len, count, ran) {
  return ran.length >= count
    ? count : -1;
}

What you learned

  • Tiering runs each function in the cheapest tier first and moves hot functions to faster tiers. A JIT compiler compiles while the program runs.
  • Ignition is V8’s interpreter: a register machine with an accumulator, whose instructions carry feedback slots.
  • V8’s optimizing tiers are Sparkplug (removes interpreter overhead), Maglev (quick optimizing) and TurboFan (best code).
  • The optimizing tiers speculate from feedback, and fall back to a lower tier when the guess fails.
  • Thresholds, tiers and defaults change between versions. Name the version when you quote one.

Next: before any of this can start, V8 has to parse the source, and it tries hard not to.

Check your understanding

0/6
  1. 1.

    Why does V8 start every function in an interpreter?

  2. 2.

    In Add a0, [0], what is [0]?

  3. 3.

    Drip’s VM needs GET_LOCAL, GET_LOCAL, ADD where Ignition needs Ldar, Add. Why?

  4. 4.

    What does Sparkplug remove, if it does no optimizations?

  5. 5.

    In the chapter’s run, why did add never use Maglev?

  6. 6.

    After TurboFan’s code for add was thrown away, what happened to the program?

References

  1. [1] Ross McIlroy (2016). Firing up the Ignition interpreter. V8 blog (v8.dev), 23 August 2016. v8.dev/blog/ignition-interpreter (checked 2026-10-31)
  2. [2] The V8 team (2017). Launching Ignition and TurboFan. V8 blog (v8.dev), 15 May 2017. v8.dev/blog/launching-ignition-and-turbofan (checked 2026-10-31)
  3. [3] Leszek Swirski (2021). Sparkplug — a non-optimizing JavaScript compiler. V8 blog (v8.dev), 27 May 2021. v8.dev/blog/sparkplug (checked 2026-10-31)
  4. [4] Toon Verwaest, Leszek Swirski, Victor Gomes, Olivier Flückiger, Darius Mercadier, Camillo Bruni (2023). Maglev - V8’s Fastest Optimizing JIT. V8 blog (v8.dev), 5 December 2023. v8.dev/blog/maglev (checked 2026-10-31)
  5. [5] The V8 project authors (2024). V8 source code at tag 12.4.254.21: src/flags/flag-definitions.h, src/execution/tiering-manager.cc and src/interpreter/interpreter-assembler.cc. V8 source repository (github.com/v8/v8), tag 12.4.254.21 (the V8 inside Node v22.23.4). github.com/v8/v8/blob/12.4.254.21/src/execution/tiering-manager.cc (checked 2026-10-31)
  6. [6] The V8 project authors (2024). V8 source code at tag 12.4.254.21: src/interpreter/bytecodes.h and src/interpreter/interpreter-generator.cc. V8 source repository (github.com/v8/v8), tag 12.4.254.21. github.com/v8/v8/blob/12.4.254.21/src/interpreter/interpreter-generator.cc (checked 2026-10-31)
  7. [7] Darius Mercadier (2025). Land ahoy: leaving the Sea of Nodes. V8 blog (v8.dev), 25 March 2025. v8.dev/blog/leaving-the-sea-of-nodes (checked 2026-10-31)