This chapter follows JavaScript under the hood. It is a focused article on one question: what an engine does between receiving source text and executing machine instructions. The examples use V8, the engine in Chrome and Node, because Node can print what V8 is doing. The section on other engines shows that SpiderMonkey and JavaScriptCore follow the same design.
Why engines use several tiers
A compiler for C or Rust spends seconds or minutes producing fast machine code, once, before the program ships. A browser cannot do that. It receives source on every page load, has to start running it right away, and a large share of the code a page ships runs rarely or not at all. Optimizing all of it up front would delay the first line and waste most of the work.
An interpreter has the opposite profile: it starts immediately but executes each operation slowly. Engines therefore use both and decide per function. Everything starts in the cheapest tier. The engine counts how often each function runs and what types flow through it, and spends compile time only on functions that have proven hot.
V8 has four tiers:
| Tier | Kind | Runs |
|---|---|---|
| Ignition | Bytecode interpreter | Every function, from its first call |
| Sparkplug | Baseline compiler, no optimization | Warm functions |
| Maglev | Fast optimizing compiler | Hot functions |
| TurboFan | Slow, thorough optimizing compiler | Hot functions with stable feedback |
flowchart TB source["Source text"] -->|"parse"| ast["AST"] ast -->|"generate"| ignition["Ignition: bytecode"] ignition -->|"warm"| sparkplug["Sparkplug: baseline machine code"] sparkplug -->|"hot"| maglev["Maglev: optimized machine code"] maglev -->|"hot and stable"| turbofan["TurboFan: highly optimized machine code"] maglev -.->|"deoptimize"| ignition turbofan -.->|"deoptimize"| ignition
The optimizing compilers run on background threads, so the main thread keeps executing bytecode while the optimized version is being built.
Parsing and lazy compilation
The scanner splits the source into tokens, and the parser builds an abstract syntax tree (AST) from them, along with scope information: which names each function declares and which outer variables it uses.
Parsing covers the whole script before any statement runs. A syntax error anywhere stops everything:
console.log('never printed');
function total(a) { return a +;}Running this prints only the SyntaxError. The console.log on line 1 never executes, because the script failed before it started.
Building a full AST and bytecode for every function would be wasteful, so V8 parses lazily. For a function that is not called yet, a faster pre-parser still checks the syntax — the specification requires those errors before execution — and records which outer variables the function refers to, so closures work later. It does not build the AST or generate bytecode. That happens on the first call, when the function is parsed again in full.
The cost of laziness is that a function called immediately is parsed twice. V8 uses a heuristic for that case: a function expression wrapped in parentheses, the classic immediately invoked function expression, is compiled eagerly.
const config = (function () { return { retries: 3 };})();Bytecode and the interpreter
Ignition is a register machine with an accumulator, a special register most instructions read from or write to. Node prints the bytecode V8 generates with --print-bytecode. The function has to be called, because uncalled functions have no bytecode yet:
function add(a, b) { return a + b;}
add(1, 2);node --print-bytecode --print-bytecode-filter=add add.jsAbridged output from Node 24:
[generated bytecode for function: add (0x23e8b7c60799 <SharedFunctionInfo add>)]Bytecode length: 6Parameter count 3Register count 0Frame size 0 21 S> 0xca75c001950 @ 0 : 0b 04 Ldar a1 30 E> 0xca75c001952 @ 2 : 3f 03 00 Add a0, [0] 34 S> 0xca75c001955 @ 5 : b3 ReturnRead the three instructions in order:
Ldar a1loads the second argument (b) into the accumulator.Add a0, [0]adds the first argument (a) to the accumulator. The[0]is a feedback slot: each time this instruction runs, it records the types it saw.Returnreturns the accumulator.
The parameter count is 3 because the receiver, this, counts as a parameter. The numbers on the left are source positions, which is how stack traces and breakpoints map bytecode back to your file.
Feedback and the optimizing tiers
Each function has a feedback vector: one slot per operation that can behave differently depending on its inputs. Arithmetic slots record whether they saw small integers, doubles, strings, or a mix. Property-access slots record the object shapes they saw. Call slots record which functions were called.
That feedback is the input to Maglev and TurboFan. If the Add above has only ever seen small integers, the optimizing compiler emits a check that both inputs are small integers, followed by a single machine add instruction with an overflow check. The generic version, which would have to handle strings, objects with valueOf, and BigInt, never appears in the optimized code.
On top of specialization, the optimizing compilers do what static compilers do: they inline small functions into their callers, remove allocations of objects that never leave the function, hoist checks out of loops, and allocate registers.
A function that is called only once but loops for a long time would never become hot under a call counter. V8 handles it with on-stack replacement (OSR): it compiles an optimized version of the function while the loop is running and switches to it in the middle of the loop.
Deoptimization
Optimized code is a bet on the feedback. Every assumption is protected by a check. When a check fails — a string arrives where only numbers were seen, or an object has a shape the code was not compiled for — the engine deoptimizes. It rebuilds the interpreter’s frame from the optimized frame’s state, continues in bytecode exactly where execution was, and records the new type in the feedback vector. If the function stays hot, it is optimized again, this time with code general enough for both cases.
Deoptimization comes in two forms:
- Eager: a check inside the optimized code fails while it runs.
- Lazy: something elsewhere in the program invalidates an assumption — for example, a field that held only numbers receives a string in some unrelated object of the same shape. The engine marks the dependent code, and it is discarded the next time it would run.
An occasional deoptimization is normal and cheap. The performance problem is a function that keeps being optimized and deoptimized, because each round costs compile time and the code never settles.
Watching the pipeline in Node
V8’s tracing flags show tier-ups and deoptimizations as they happen. This loop calls area 200,000 times with numbers, then once with a string:
function area(rect) { return rect.w * rect.h;}
let sum = 0;for (let i = 0; i < 200_000; i++) { sum += area({ w: i, h: 2 });}sum += area({ w: '3', h: 2 });console.log(sum > 0);node --trace-opt --trace-deopt deopt.jsAbridged output from Node 24, with addresses removed:
[marking <JSFunction area> for optimization to MAGLEV, ConcurrencyMode::kConcurrent, reason: hot and stable][completed compiling <JSFunction area> (target MAGLEV) - took 0.001, 0.213, 0.005 ms][marking <JSFunction area> for optimization to TURBOFAN_JS, ConcurrencyMode::kConcurrent, reason: hot and stable][completed optimizing <JSFunction area> (target TURBOFAN_JS)][marking dependent code <Code TURBOFAN_JS> (<SharedFunctionInfo area>) for deoptimization, reason: dependent field representation changed][marking dependent code <Code MAGLEV> (<SharedFunctionInfo area>) for deoptimization, reason: dependent field representation changed][marking <JSFunction area> for optimization to TURBOFAN_JS, ConcurrencyMode::kConcurrent, reason: hot and stable]The function climbs through Maglev to TurboFan within the loop. Creating { w: '3', h: 2 } stores a string in a field that until then only held small integers, which changes how that field is represented for every object of that shape. Both optimized versions depended on the old representation, so both are marked for lazy deoptimization. The last line is V8 optimizing again with the wider feedback.
These flags expose V8 internals. Their names and output change between versions, so use them to explore, not in tests or tooling. For day-to-day profiling, the Performance panel in Chrome DevTools, or node --cpu-prof, shows where time goes without this level of detail. V8 and the REPL in NodeJS lists how to see every V8 option.
Other engines
SpiderMonkey and JavaScriptCore were designed independently and arrived at the same structure: interpret first, collect feedback, compile hot code in stages, and bail out when assumptions break. V8 runs in Chrome, Edge, Node, and Deno; SpiderMonkey in Firefox; JavaScriptCore in Safari and Bun.
| Engine | Interpreters | Compilers, cheapest first |
|---|---|---|
| V8 | Ignition | Sparkplug, Maglev, TurboFan |
| SpiderMonkey | Interpreter, Baseline Interpreter | Baseline Compiler, WarpMonkey |
| JavaScriptCore | LLInt | Baseline JIT, DFG JIT, FTL JIT |
The thresholds and heuristics differ, so a trick tuned to one engine’s internals can do nothing in another. What helps in all of them is the common ground: functions that see consistent types, and objects that keep consistent shapes.
Caching compiled code
Parsing and compiling cost CPU time on every load, so engines cache the result. Chrome stores V8’s bytecode alongside the HTTP cache for scripts that run repeatedly, and later visits skip most of the compile work for those scripts.
Node does not cache compiled code by default. Since Node 22.1 you can enable an on-disk cache for loaded modules, either from code or with an environment variable:
import module from 'node:module';
module.enableCompileCache();NODE_COMPILE_CACHE=.cache/node node src/main.jsThe cache holds bytecode, not optimized machine code. Each process still warms up and tiers up again, because optimized code depends on feedback from that particular run.
What this means for your code
You rarely interact with the JIT directly, and most code should be written for clarity first. A few habits do make a measurable difference in hot paths:
- Ship less JavaScript. Every byte is at least scanned and pre-parsed, even if it never runs. Code splitting and removing dead dependencies reduce startup work more than any micro-optimization.
- Keep types consistent per operation. A function that adds numbers should receive numbers. Passing a string to it once is fine; alternating between strings and numbers keeps it generic.
- Keep object shapes consistent. This is the subject of the next chapter.
- Avoid direct
evalin hot functions. Code that can be evaluated at runtime may reference any variable by name, so the engine has to keep the whole scope available and cannot optimize as freely. - Measure with realistic workloads. A microbenchmark that calls one function a million times with the same input measures TurboFan’s best case, and the compiler may remove work whose result is unused. Profile the real application before changing code for performance.
Check your understanding
Question 1 of 9
0 of 9 checked