Optimizing compilers
Learn how JavaScript optimizing compilers build SSA, graphs, engine pipelines, and background threads that turn hot code into fast machine code.
- 01Explain why optimizers build an IRSay why an engine rebuilds a hot function in its own private form, the IR, before it makes machine code.
- 02Use SSA and graph wordsUse a small function to explain SSA names, phi nodes, control-flow graphs, and Sea of Nodes graphs.
- 03Compare real enginesGive the job of V8's Maglev, TurboFan, and Turboshaft, Firefox's WarpMonkey, and Safari's DFG, FTL, and B3.
An optimizer rebuilds the function first
A JavaScript engine is the program inside a browser or Node that runs your code. A function that runs very often is called hot. For hot functions, the engine uses its strongest compilers. To compile means to turn code into another form before running it. Machine code is the form the computer's processor runs directly.
A strong compiler does not jump straight from your source text to machine code. First it rebuilds the function in its own private form. That form is called an intermediate representation, or IR. In the IR, the compiler can:
- give each value its own name,
- join up the two paths of an
if, - attach feedback: the notes the engine kept about the values each step has seen, such as "this was always a small whole number",
- choose the machine instructions.
None of this changes what your JavaScript means.
An optimizing compiler is a JIT ("just in time") compiler for hot code: it compiles while the program is running. It spends extra time compiling. It builds one or more IR graphs: drawings made of parts joined by lines. It uses feedback to fit the code to the values it has seen. Then it makes faster machine code that gives exactly the same JavaScript result.
The Baseline compilers lesson covered quick compilers such as V8's Sparkplug. This lesson picks up from there. It shows the strongest compilers in three engines:
- V8, the engine in Chrome and Node: Maglev and TurboFan.
- SpiderMonkey, the engine in Firefox: WarpMonkey.
- JavaScriptCore, the engine in Safari: DFG and FTL.
The next lesson, Speculation & deoptimization, covers the checks that protect the compiler's guesses. This lesson covers IRs, SSA, graphs, the steps inside each engine, and compiling on a second thread.
SSA: every value gets its own name
SSAIn JavaScript, a variable can change many times. That makes it hard for a compiler to know which value a line reads. So many compilers use SSA, short for single static assignment. In SSA, each name in the IR gets a value exactly once. When your code changes a variable, the compiler makes a new name instead.
Your JavaScript variables still change as normal. Only the compiler's private copy uses the new names.
function finalPrice(isMember) { let discount = 5; if (isMember) { discount = 20; } return 100 - discount;} console.log(finalPrice(true));console.log(finalPrice(false));In plain words, line by line:
- Line 2 sets
discountto5. - Line 4 changes it to
20, but only for a member. - Line 6 subtracts whichever value
discountholds now. - Lines 9 and 10 call the function both ways. They print
80for a member and95for someone who is not a member.
In SSA, the compiler gives each value of discount its own name:
discount0 = 5is the first value.discount1 = 20is the member value.discount2is the value after theif.
After the if, the code can arrive by two paths. A phi node (say "fie") is a special IR step at the place where the paths join. It picks the value from the path that ran: discount1 for a member, and discount0 for anyone else.
if, the phi fills discount2 with the value from the path that ran.A cook fills each labeled lunch box once and never changes what is inside. Two boxes may be sent to the same table, but only one of them comes. The table uses the box that came.
- In real life: Each lunch box gets its own label
- In JavaScript: Each SSA value gets a new name, such as
discount0 - In real life: A filled box is never emptied and refilled
- In JavaScript: An SSA name gets its value once, then is only read
- In real life: Two boxes may come to the same table
- In JavaScript: A phi node picks the value from the path that ran
- In real life: The table uses the box that came
- In JavaScript: Later code reads one name,
discount2
The step-through below runs a small converter written in plain JavaScript. It is a model, not V8 itself. It really builds the SSA lines shown beside the code.
Step through a small, real SSA converter. It is a model written in JavaScript, not output from V8.
script
function convertDiscountToSsa() { const nextVersion = {}; function name(base) { const version = nextVersion[base] ?? 0; nextVersion[base] = version + 1; return base + version; } const total = name("total"); ssa.push(total + " = parameter total"); const regularDiscount = name("discount"); ssa.push(regularDiscount + " = 5"); const memberDiscount = name("discount"); ssa.push(memberDiscount + " = 20"); const mergedDiscount = name("discount"); ssa.push(mergedDiscount + " = phi(" + memberDiscount + ", " + regularDiscount + ")"); const payable = name("payable"); ssa.push(payable + " = " + total + " - " + mergedDiscount); return ssa;} console.log(convertDiscountToSsa().join(" | "));Maglev: a fast SSA compiler in V8
V8Maglev is one of V8's optimizing compilers. V8's Maglev article calls it a simple JIT compiler built on SSA. It arrived in Chrome version 117 (M117). It sits between two other V8 compilers:
- Sparkplug, the quick baseline compiler from the last lesson,
- TurboFan, V8's strongest and slowest compiler.
Maglev makes code that is faster than Sparkplug's. It makes that code much sooner than TurboFan can.
The model below shows how feedback changes the plan. Two words first. An object's shape is its layout: which property names it has, in which order. Smi is short for "small integer", a small whole number.
const stableFeedback = { shape: "CartShape", priceType: "smi" };const changedFeedback = { shape: "OtherShape", priceType: "mixed" }; function maglevPlan(feedback) { if (feedback.shape !== "CartShape" || feedback.priceType !== "smi") { return ["GenericPropertyLoad", "GenericAdd", "Return"]; } const graph = []; graph.push("CheckShape " + feedback.shape); graph.push("CheckSmi price"); graph.push("LoadField price"); graph.push("Int32Add"); graph.push("Return"); return graph;} console.log("stable", maglevPlan(stableFeedback).join(" -> "));console.log("changed", maglevPlan(changedFeedback).join(" -> "));In plain words, line by line:
- Lines 1 and 2 stand in for feedback from the earlier steps.
stableFeedbacksays every cart had the same shape and every price was a smi.changedFeedbacksays other shapes and other kinds of values showed up. - Line 5 checks the feedback. If it is not the steady kind, line 6 returns a generic plan: steps that work for any value, but slowly.
- Lines 9 to 13 build the special plan. Check the shape. Check that the price is a smi. Read the price straight from its known place. Add with a fast whole-number add. Return.
- Line 16 prints
stableand then the special plan:CheckShape CartShape -> CheckSmi price -> LoadField price -> Int32Add -> Return. - Line 17 prints
changedand then the generic plan:GenericPropertyLoad -> GenericAdd -> Return.
A check step such as CheckShape is a guard. If the guess turns out wrong, the optimized code stops and hands the work back to slower code. The next lesson covers that. Real Maglev starts from bytecode and feedback, and builds a graph in SSA form. This model only teaches the words.
The table below places each optimizing compiler. Read each row as one compiler, its job, and how to think about it.
| Compiler | Its job | How to think about it |
|---|---|---|
| Maglev | A fast SSA compiler in V8. | It builds an SSA graph from bytecode and feedback, then makes fairly fast code quickly. |
| TurboFan | V8's strongest compiler. | It spends more time compiling to make the fastest code. Its JavaScript backend now uses Turboshaft. |
| Turboshaft | V8's newer backend. | It makes the machine code. It uses a CFG-shaped IR in place of TurboFan's old Sea of Nodes backend. |
| WarpMonkey | Firefox's optimizing compiler. | It uses bytecode and saved CacheIR steps to build MIR, then optimized code. |
| FTL with B3 | Safari's top step. | FTL does the high-level work for hot code, and B3 makes its machine code. |
The lesson tests run Node 22, which uses V8 version 12.4. They list V8's settings with node --v8-options. The list shows three things:
- Maglev is off by default in this Node:
--maglevdefaults to--no-maglev. - TurboFan is on.
- Concurrent recompilation, which means compiling on a second thread, is on.
Even with the --maglev flag, a test that asks for Maglev gets the message "Maglev is not enabled". So the real trace output later in this lesson comes from TurboFan.
TurboFan: two ways to draw a program
GRAPHSTurboFan is V8's strongest optimizing compiler. Like most compilers, it keeps its IR as a graph. The parts of the graph are called nodes, and the lines between them are called edges. There are two common shapes:
- A control-flow graph, or CFG, cuts the code into basic blocks. A basic block is a run of steps with no branch in the middle. Edges between blocks show which block can run next.
- A Sea of Nodes graph has no fixed blocks at first. Each node is one operation. Edges show which values a node needs, which effects (such as a property write) must happen first, and which branch it belongs to.
For years, TurboFan was known for using Sea of Nodes. In 2025, V8's article "Land ahoy: leaving the Sea of Nodes" said that TurboFan's JavaScript backend now uses Turboshaft. The backend is the last part of a compiler, the part that makes machine code. Turboshaft uses the more usual CFG shape.
function chooseBonus(a, b, flag) { const left = a * 2; const right = b * 2; const picked = flag ? left : right; return picked + 1;} console.log(chooseBonus(3, 4, true));console.log(chooseBonus(3, 4, false));- Entry block: work out left and right, then check flag
- Then block: picked is left
- Else block: picked is right
- Merge block: return picked + 1
The program still prints 7 and 9. The IR is only the compiler's tool. It does not change what your JavaScript means.
Control-flow graph: A CFG puts the steps into blocks and joins the blocks with lines in the order they can run. You can read it from top to bottom. This is a model. It does not look inside your browser's engine.
There is one catch. In JavaScript, a * 2 on line 2 can run your own code. For example, an object can have a valueOf method that JavaScript calls to turn the object into a number. Turning a value into another type like this is called coercion. So the compiler cannot move line 2 or line 3 around freely.
The playground's Sea of Nodes view starts after two checks. Feedback said a and b were numbers, and number checks proved it. Only then are the two multiplications free to move. A CFG view shows blocks and branches. A Sea of Nodes view shows which values depend on which.
The table compares the two shapes. Read each row as one shape, what it looks like, and why a compiler picks it.
| Graph shape | What it looks like | Why a compiler uses it |
|---|---|---|
| Control-flow graph (CFG) | Basic blocks, joined by lines for each branch and jump. | The order the steps run in is easy to see. |
| Sea of Nodes | Single operations, joined by the values, effects, and branches they depend on. | Work that depends on nothing else can move freely until the compiler picks the final order. |
| Turboshaft | V8's JavaScript backend now uses a CFG-shaped IR. | It keeps most of the speed and makes the compiler simpler to build and quicker to run. |
function chooseBonus(a, b, flag) { const left = a * 2; const right = b * 2; const picked = flag ? left : right; return picked + 1;} console.log(chooseBonus(3, 4, true));console.log(chooseBonus(3, 4, false));Line 2 doubles a. Line 3 doubles b. Line 4 uses flag to pick one of them. Line 5 adds 1. The program prints 7 (3 × 2 + 1) and then 9 (4 × 2 + 1).
- V8's Maglev article calls Maglev a simple JIT compiler built on SSA, added in Chrome M117.
- V8's "leaving the Sea of Nodes" article describes Turboshaft and the move to CFG-shaped IRs.
WarpMonkey turns cache notes into MIR
SPIDERMONKEYSpiderMonkey is the engine in Firefox. Its optimizing compiler is WarpMonkey, or Warp for short. Warp starts from inline caches, or ICs: the small saved shortcuts from the Inline caches lesson. SpiderMonkey writes each IC in a simple format called CacheIR. One saved list of CacheIR steps is called a stub.
When Warp optimizes a function, three things happen, in order:
- WarpOracle takes a snapshot: a frozen copy of the function's bytecode and its useful IC data.
- WarpBuilder and the WarpCacheIRTranspiler turn that CacheIR into MIR. MIR is SpiderMonkey's middle-level IR. A transpiler turns code from one format into another.
- Later, MIR becomes LIR, a lower-level IR close to machine code, and then machine code.
const cacheIR = ["GuardShape", "LoadFixedSlotResult"]; function warpToMir(cacheIR) { return cacheIR.map((op) => { if (op === "GuardShape") return "MGuardShape"; if (op === "LoadFixedSlotResult") return "MLoadFixedSlot"; return "MUnsupported"; });} console.log(warpToMir(cacheIR).join(" -> "));In plain words, line by line:
- Line 1 is the IC's record.
GuardShapechecks the object's shape.LoadFixedSlotResultreads a property from a fixed place inside the object. - Lines 4 to 6 go through each step and swap in the matching MIR name. Line 7 handles any step the model does not know.
- Line 11 prints
MGuardShape -> MLoadFixedSlot.
Real Warp also uses what it saw about calls. Inlining means copying a called function's body into the caller, so no call is needed when the code runs. In trial inlining, Warp tries this for hot called functions before it builds the final code.
The Firefox CacheIR docs describe CacheIR stubs, snapshots, and the WarpCacheIRTranspiler. The SpiderMonkey overview names WarpMonkey as the top optimizing step. It says Warp turns bytecode and IC data into MIR, then LIR, then machine code.
JavaScriptCore: DFG, FTL, and B3
JSCJavaScriptCore is the engine in Safari. It uses different names:
- The DFG (Data Flow Graph) JIT is an optimizing compiler.
- The FTL (Faster Than Light) JIT is the stronger top step.
- B3, the Bare Bones Backend, is the part of FTL that makes the final machine code. WebKit's B3 article says B3 replaced LLVM in that job. LLVM is a large, general-purpose compiler toolkit.
const jscTiers = ["LLInt", "Baseline JIT", "DFG", "FTL (B3 backend)"]; function describeJscPipeline(tiers) { return tiers.join(" -> ");} console.log(describeJscPipeline(jscTiers));In plain words, line by line:
- Line 1 lists the steps in order.
LLIntis the low-level interpreter, where code starts.Baseline JITis the quick compiler. - Line 4 joins the names with arrows.
- Line 7 prints
LLInt -> Baseline JIT -> DFG -> FTL (B3 backend).
B3 is written inside FTL's entry because it is part of FTL, not a step after it. When code moves from one step to the next is up to each engine. It never changes what your JavaScript means.
The table puts the three engines side by side. Read each row as one engine and its optimizing compilers.
| Engine | Middle optimizer | Top optimizer and backend | What to remember |
|---|---|---|---|
| V8 | Maglev | TurboFan, with Turboshaft as its backend | Ignition and Sparkplug supply bytecode and feedback. The tested Node 22 (V8 12.4) keeps Maglev off by default. |
| SpiderMonkey | WarpMonkey | MIR, turned into LIR, then machine code | Warp snapshots the saved CacheIR steps and turns them into MIR. Trial inlining uses the calls it saw. |
| JavaScriptCore | DFG | FTL, with B3 as its backend | DFG does the high-level work. FTL uses B3 to make the final machine code. |
WebKit's B3 article says FTL uses DFG for the high-level work and B3 as its backend. WebKit's article on speculation explains why these steps make guesses about JavaScript values, and why they must be ready to recover when a guess is wrong.
Compiling on a second thread
THREADSA thread is one line of work that the computer runs. Your JavaScript runs on the main thread. Concurrent compilation means the engine builds optimized code on a background thread, a second line of work. Meanwhile, the main thread keeps running the older code.
The compiler works from:
- a snapshot, a frozen copy of the facts it needs,
- the feedback,
- a list of things that must stay true for the new code to be correct.
The engine switches to the new code only when it is ready and safe. This switch is called installing the code.
Lunch keeps being served while one cook plans a faster order for the dishes. When the plan is ready, the kitchen starts using it. The meal itself does not change.
- In real life: Lunch keeps going out to the tables
- In JavaScript: The main thread keeps running the baseline code
- In real life: One cook plans a faster serving order
- In JavaScript: The optimizer builds code on a background thread
- In real life: The new order starts when the plan is ready
- In JavaScript: Optimized code is installed after it finishes compiling
- In real life: The meal stays the same
- In JavaScript: The JavaScript result is the same before and after
Step through a model of compiling on a second thread. It shows what each thread does. It does not look inside your browser's engine.
script
function buildConcurrentTimeline(callsBeforeReady = 3) { const events = []; events.push("main: run baseline call 1"); events.push("background: compile optimized graph"); for (let call = 2; call <= callsBeforeReady; call += 1) { events.push("main: run baseline call " + call); } events.push("main: install optimized code when it is ready"); events.push("main: run optimized call " + (callsBeforeReady + 1)); return events;} The table splits the work into three parts. Read each row as one part, its job, and why it matters.
| Part | Its job | Why it matters |
|---|---|---|
| Main thread | It keeps running the baseline code or older optimized code. | The page keeps responding, instead of freezing while a compile finishes. |
| Background thread | It builds the optimized graph and the machine code. | It works from a snapshot and checks, because the program keeps running and changing. |
| Install point | The engine switches to the new code when it is ready and safe. | The JavaScript result is the same before and after the switch. |
Now the real thing. The lesson tests run the code below in Node with --trace-opt. That flag makes V8 print a line each time it starts optimizing a function.
function hotSum(x) { let total = 0; for (let i = 0; i < 200; i += 1) total = (total + x + i) | 0; return total;} for (let n = 0; n < 20000; n += 1) hotSum(n);Default:
[compiling method ... hotSum ... (target TURBOFAN), mode: ConcurrencyMode::kConcurrent]
With --no-concurrent-recompilation:
[compiling method ... hotSum ... (target TURBOFAN), mode: ConcurrencyMode::kSynchronous]
With --allow-natives-syntax --maglev:
Maglev is not enabled.// Node only. Run: node --v8-options
// The lesson tests check only these settings:
// --maglev defaults to --no-maglev in Node 22 (V8 12.4).
// --turbofan and --concurrent-recompilation are on by default.In the first block, line 1 defines hotSum. Line 3 gives it a loop of work. Line 7 calls it 20,000 times, so it becomes hot. In the trace lines:
(target TURBOFAN)means TurboFan compiles the function.ConcurrencyMode::kConcurrentmeans it compiles on a background thread. This is the default.- With
--no-concurrent-recompilation, the mode becomesConcurrencyMode::kSynchronous. The main thread waits while the function compiles. - With
--allow-natives-syntax --maglev, a test that asks for Maglev printsMaglev is not enabled.
The last block is a note, not code to run. It lists the V8 settings from node --v8-options that the tests check.
The tests check only these parts, because they are the same on every run. They do not check timings, memory addresses, or exact version numbers.
Use this knowledge safely
Optimizing compilers explain two things you may notice. Code often gets faster after it has run a while; this is called warm-up. And a small change to hot code can change its speed. But these compilers are not a set of hidden switches for you to flip. In your own code:
- Write clear JavaScript.
- Keep your objects the same shape when that is natural.
- Measure what the user waits for before you change code to make it faster.
When you measure speed:
- Warm up a small speed test (a microbenchmark) first. Then report many runs. Do not trust the first, slow run.
- Use the browser's performance tools to find the slow part of the page before you think about compilers.
- Choose simple, readable code over clever code written for one named compiler.
- When the optimizer surprises you, the next lesson on speculation and deoptimization often explains why.
- Writes to
discountbecomediscount0,discount1, and a phi. - V8's newer backend, which uses a CFG shape in place of the old Sea of Nodes backend.
- Firefox's saved inline-cache steps, which Warp turns into MIR.
- Build optimized code while the main thread keeps running the old code.
- The part of Safari's FTL that makes the final machine code.
Put each card under the kind of idea it describes.
Common misconceptions
"SSA means my JavaScript variables cannot change."
No. SSA is only how the compiler names values in its private copy. Your JavaScript variables change exactly as before.
"Sea of Nodes and CFGs are just drawings."
No. They are two ways to build the IR. The shape decides which compiler steps are easy to write and quick to run.
"If Node lists the Maglev flag, Node uses Maglev."
No. The tested Node 22 (V8 12.4) lists the flag, but Maglev is off. A test that asks for Maglev gets "Maglev is not enabled".
"Concurrent compilation runs my function on a second thread."
No. Only the compiling happens on the second thread. Your function still runs on the main thread, as normal JavaScript does.
The table below lists four terms. Read each row as a term, what it means, and what it does not mean.
| Term | What it means | What it does not mean |
|---|---|---|
| IR | The compiler's private copy of the program. | A JavaScript feature you can see or use in your code. |
| SSA | A naming rule: each name gets a value only once. | A rule that your JavaScript variables cannot change. |
| Phi node | A step that picks a value where two paths join. | A function that your JavaScript calls. |
| Concurrent compilation | Compiling can happen on a second thread. | Your function runs on two threads at once. |
Practice exercises
6 EXERCISESWhat number does this print for a member?
function finalPrice(isMember) {
let discount = 5;
if (isMember) discount = 20;
return 100 - discount;
}
console.log(finalPrice(true));console.log(100 - 20);finalPrice(true) takes the member path, so it prints 80.
In SSA, which step picks between the member discount and the regular discount after the if?
A phi node joins the paths. Here discount2 = phi(discount1, discount0) gives later code one name to read.
Which graph shape cuts the code into basic blocks?
A control-flow graph cuts the steps into basic blocks and joins the blocks with lines for each branch.
Warp turns the saved CacheIR steps into which middle-level IR?
WarpBuilder and the WarpCacheIRTranspiler turn the CacheIR snapshot into MIR. Later, SpiderMonkey turns MIR into LIR.
This timeline installs the optimized code too early. When should the install step happen?
const timeline = [
"main: install optimized code",
"background: compile optimized graph"
];
console.log(timeline.join(" -> "));const timeline = [
"background: compile optimized graph",
"main: install optimized code"
];
console.log(timeline.join(" -> "));The engine can install optimized code only after the compiler has made it and the engine decides it is safe.
A checkout page feels slow after someone rewrote some of its code. What should you do before you change code for Maglev, TurboFan, Warp, or FTL?
Measure first. Use the browser's performance tools, warm up small speed tests, and keep the code clear. Only then change code for speed.
Quiz: check your understanding
8 QUESTIONSUse the IR words, the engine names, and the two threads from this lesson.
Question 1 of 8Why does an optimizing compiler build an IR before it makes machine code?
Choose an answer to see the explanation.
Question 2 of 8What does this branch example print first?
Read the code, then predictfunction finalPrice(isMember) { let discount = 5; if (isMember) discount = 20; return 100 - discount; } console.log(finalPrice(true));Choose an answer to see the explanation.
Question 3 of 8In SSA, what does a phi node do?
Choose an answer to see the explanation.
Question 4 of 8Which sentence matches V8's description of Maglev?
Choose an answer to see the explanation.
Question 5 of 8What does this timeline model print last?
Read the code, then predictconst events = []; events.push("main: baseline call"); events.push("background: compile"); events.push("main: install optimized"); console.log(events.at(-1));Choose an answer to see the explanation.
Question 6 of 8What did V8 change in 2025 with Turboshaft?
Choose an answer to see the explanation.
Question 7 of 8What does Firefox's Warp do with CacheIR?
Choose an answer to see the explanation.
Question 8 of 8What is the safest way to use what you know about optimizers in a real app?
Choose an answer to see the explanation.
Key takeaways
- An optimizing compiler first rebuilds a hot function as an IR, its own private form. Then it makes faster machine code.
- In SSA, each value gets a name exactly once. A phi node picks the value from the path that ran.
- V8 uses Maglev for quick optimizing and TurboFan, with its Turboshaft backend, for the strongest code. Node 22 (V8 12.4) keeps Maglev off by default.
- SpiderMonkey's Warp turns CacheIR into MIR. JavaScriptCore uses DFG, then FTL, with B3 as FTL's backend.
- Concurrent compilation builds optimized code on a background thread while the main thread keeps running the old code.
Remember the one-liner.
An optimizing compiler rebuilds hot code as a graph, makes it faster, and switches to the new code only when it gives the same result.
Up next: Speculation & deoptimization. You'll see the guards that check the compiler's guesses, and what happens when a guess is wrong.