Understanding tiered JIT compilation explains why consistent, predictable code is fast and why benchmarks mislead.
Modern engines don't simply interpret JavaScript, and they don't compile it all up front either. They use tiered compilation, spending optimisation effort only on code that turns out to be hot.
Using V8 (Chrome, Node.js) as the example:
- Parsing: source becomes an AST. Functions are pre-parsed lazily — only checked for syntax — and fully parsed when first called.
- Ignition, the interpreter, compiles each function to compact bytecode and runs it. While running, it records type feedback in a per-function feedback vector: which object shapes were seen at each property access, whether
+saw small integers or strings, which functions were called. - Sparkplug, a baseline compiler, quickly translates bytecode for warm functions into machine code without optimising.
- Maglev, a fast mid-tier optimising compiler, and TurboFan, the top tier, use the feedback to generate speculative machine code: "this
a + balways sees small integers", "thisobj.xalways sees the same shape". Guards check each assumption cheaply.
When a guard fails — a string shows up where only integers did, an object with a new shape arrives — the engine deoptimises: it discards the optimised code, reconstructs the interpreter frame and continues in bytecode. Frequent deopt/re-opt cycles are a real performance problem. Long-running loops can be optimised mid-execution via on-stack replacement (OSR).
SpiderMonkey (Baseline Interpreter, Baseline JIT, Warp) and JavaScriptCore (LLInt, Baseline, DFG, FTL) follow the same idea with different tiers.
Practical consequences: keep hot functions type-stable (same argument types and object shapes), don't benchmark cold code, and remember that micro-benchmarks can be optimised in ways your real program won't be. V8 flags like --trace-opt and --trace-deopt show these decisions.
Example
// Run: node --trace-opt --trace-deopt jit.mjs | grep -E "add|sum"
// (V8 debug flags; exact output varies between versions)
function add(a, b) {
return a + b;
}
let sum = 0;
for (let i = 0; i < 1_000_000; i++) {
sum = add(sum, i); // feedback: always small ints / doubles
}
console.log(sum); // add gets marked for optimisation
console.log(add("type ", "change")); // new feedback: strings
// The optimised code's guard fails -> "deoptimizing ... add"
// Later calls run in bytecode until V8 re-optimises with broader types.Remember
- Engines interpret first, gather type feedback, then compile hot code speculatively
- A failed assumption triggers deoptimisation back to bytecode
- Type-stable hot functions optimise best; warm up before measuring
Watch out for
- Drawing conclusions from micro-benchmarks that the optimiser has reduced to nothing.