Tiered compilation
Learn how JavaScript engines start with interpreters, watch hot code, then tier up through baseline and optimizing JIT compilers.
- 01Explain when compiling pays backCompare an interpreter, an ahead-of-time compiler, and a JIT compiler: how fast each one starts, how fast its code runs, and when compiling is worth the time.
- 02Name the main browser tiersList V8's tiers (Ignition, Sparkplug, Maglev, TurboFan) and the tier names in SpiderMonkey and JavaScriptCore.
- 03Spot warm-up in real workExplain how counters move code up a tier, why benchmarks need a warm-up, and why real code should never be written for one tier.
Start simple, then specialize
A JavaScript engine is the program inside a browser or Node that runs your code. When a page loads, the engine does not know which functions will matter. A small helper might run once. A function that draws each frame of an animation might run thousands of times.
The engine can run a function in two broad ways:
- Read it one step at a time. This starts at once, but runs slowly.
- First turn it into machine code, the instructions the computer's chip runs directly. This takes time first, but then runs fast.
Turning code into machine code is called compiling. The time it takes is the compile cost. Tiered compilation is the engine's plan: start every function the cheap way, watch which ones run a lot, and spend compile time only on those.
Tiered compilation means the engine has several levels, called tiers, for running code. New code starts in a quick, cheap tier. The engine counts how often each function runs. A function that runs a lot is hot; one that rarely runs is cold. Hot functions are compiled again into faster tiers, which cost more to build.
This lesson covers:
- interpreters, compilers, and JIT compilers;
- the tier names in V8, SpiderMonkey, and JavaScriptCore;
- the counters that decide when code moves up a tier.
The next lessons go deeper on type feedback and inline caches. Later lessons cover what the engine does when its guesses about your code turn out wrong. For the bigger picture, go back to Meet the engines, Bytecode & interpreters, and Startup performance.
Interpreter, compiler, and JIT: when each one pays
TRADE-OFFThere are three ways to run code. Each one pays its cost at a different time:
- An interpreter runs bytecode, a simple list of steps the engine makes from your code. It runs one step at a time. It starts almost at once. But every step costs a little extra, because the interpreter must look up what each step means.
- An ahead-of-time compiler turns the whole program into machine code before it runs. The result runs fast. But doing this for every function on a page would make the page slow to start.
- A just-in-time (JIT) compiler compiles while the program is already running. It waits until a function has run enough to show that the compile cost will pay back.
A new school starts with a rough timetable on day one. Over the next weeks, the staff improve the plan for the busiest classes first. The rough plan lets school start at once. The careful work goes where it helps the most students.
- In real life: A rough timetable works on the first day
- In JavaScript: An interpreter, such as V8's Ignition, starts new code quickly
- In real life: A better timetable takes time to prepare
- In JavaScript: Fast machine code costs compile time first
- In real life: The busiest classes get the careful work
- In JavaScript: Only hot functions get the expensive tiers
- In real life: A quick fix helps before the final plan is ready
- In JavaScript: A baseline compiler makes faster code without heavy work
The code below is a cost model. It uses made-up time units to show the idea. It is not real engine timing.
function totalTime(compileCost, callCost, calls) { return compileCost + callCost * calls;} console.log(totalTime(0, 5, 4)); // interpreter: cheap startconsole.log(totalTime(120, 0.6, 4)); // top tier: too much compile costconsole.log(totalTime(120, 0.6, 400)); // top tier: compile cost pays back- Line 1 defines
totalTime. It takes the compile cost, the cost of one call, and the number of calls. - Line 2 adds the compile cost to the cost of all the calls.
- Line 5 is the interpreter: nothing to compile, 5 units per call, 4 calls. It prints
20. - Line 6 is the top tier with only 4 calls: 120 to compile, 0.6 per call. It prints
122.4. The compile cost is far bigger than the time the fast calls save. - Line 7 is the top tier with 400 calls. It prints
360. The interpreter would need 2000 units for 400 calls, so here compiling pays back.
Each row of the next table is one tier. Read across to see how long the tier takes to build, how fast its code runs, and what code it suits. Not every function climbs all the tiers; most stay low.
| Tier | How long it takes to build | How fast it runs | What code it suits |
|---|---|---|---|
| Interpreter | Almost no time. | Slowest for each step. | Cold code, startup, and code that runs once. |
| Baseline JIT | A little time. | Faster: simple machine code, with no lookup for each step. | Functions that ran enough to be worth quick machine code. |
| Mid-tier optimizing JIT | Some time. | Good code, made quickly. | Hot code that is not yet worth the top tier's long compile. |
| Top-tier optimizing JIT | The most time. | Fastest, once it has seen how the code behaves. | Very hot loops, and functions that keep running for a long time. |
V8: Ignition, Sparkplug, Maglev, TurboFan
V8V8 is the engine in Chrome and Node. It has four tiers, in this order:
- Ignition is the interpreter. It runs bytecode.
- Sparkplug is a baseline compiler. It turns bytecode straight into simple machine code, without trying to make it clever. V8 announced it in 2021. It is quick enough to help code that only runs for a short time.
- Maglev is a mid-tier optimizing compiler. An optimizing compiler uses what it has seen the code do to make faster machine code. Maglev makes good-enough code quickly. It arrived in Chrome M117 in 2023.
- TurboFan is the top tier. It spends the most compile time to make the fastest code.
TurboFan's inside is changing too. Before a compiler makes machine code, it turns your code into a working form of its own. TurboFan used a form called "Sea of Nodes". V8's 2025 post "Land ahoy: leaving the Sea of Nodes" describes the move to a new back end called Turboshaft. Turboshaft uses a form that keeps the code's steps in order, called a control-flow graph.
Chrome and Node can turn on different V8 tiers. The Node 22.23.1 build used for this lesson has Sparkplug and TurboFan turned on. Its --v8-options list shows Maglev as off by default (--no-maglev). Asking it to use Maglev prints "Maglev is not enabled." Chrome does use Maglev.
- A function has just been turned into bytecode and runs for the first time in V8.
- V8 turns bytecode into simple machine code, without heavy optimizing.
- A hot function needs better code soon, but is not yet worth the top tier's long compile.
- A very hot function is worth the longest compile, to get the fastest code.
- JavaScriptCore starts code in a low-level interpreter, before its JIT tiers.
Put each card in the group it describes. The groups are general ideas; each engine picks its own exact numbers.
SpiderMonkey and JavaScriptCore use the same idea
OTHER ENGINESOther engines use the same plan with different names.
SpiderMonkey is Firefox's engine. It keeps a warm-up counter for each function. The counter goes up each time the function starts, and each time one of its loops goes round. SpiderMonkey's tiers are:
- the C++ interpreter;
- the Baseline Interpreter;
- the Baseline Compiler;
- Ion, the optimizing compiler.
In 2020, the Warp project changed how Ion gets its information. A part called WarpBuilder saves a copy of the bytecode and of what the code has seen so far. Then Ion's working form of the code, called MIR, is built on a background thread. A background thread is a second line of work, so the page does not have to wait for it.
JavaScriptCore is Safari's engine. Its tiers are LLInt (the interpreter), Baseline JIT, DFG, and FTL. WebKit's 2016 B3 post explains that FTL moved from the LLVM back end to its own B3 back end. WebKit's 2020 post on speculation explains how JavaScriptCore makes careful guesses about your code so it can run it fast.
The details differ, but the shape is the same: a cheap start, a quick baseline tier, then stronger optimizing tiers.
Each row of the next table is one engine. Read across from the tier where code starts to the top tier.
| Engine | Where code starts | Baseline tier | Middle tier | Top tier |
|---|---|---|---|---|
| V8 | Ignition | Sparkplug | Maglev | TurboFan, whose back end is moving from Sea of Nodes to Turboshaft |
| SpiderMonkey | The C++ interpreter, then the Baseline Interpreter | Baseline Compiler | No separate middle tier | Ion, the optimizing compiler, which the Warp project feeds with what the code has seen |
| JavaScriptCore | LLInt | Baseline JIT | DFG JIT | FTL JIT, which uses the B3 back end |
Hotness counters: when code moves up a tier
COUNTERSThe engine needs a sign that code is hot. The simplest sign is a call counter: each call to a function adds 1. When the count reaches a set number, called a threshold, the engine moves the function to a stronger tier. This is called tiering up.
Real engines use more signals than one counter:
- how many times a function was called;
- how many times its loops went round;
- feedback: notes about the kinds of values the code has seen;
- other rules that each engine picks.
The optimizing compile can run on a background thread. Meanwhile, the main thread (the one running your page) keeps running the older, slower code until the new code is ready.
const thresholds = { baseline: 6, mid: 30, top: 90 };let tier = "interpreter";let invocationCount = 0; function scoreCart(items) { invocationCount += 1; if (invocationCount === thresholds.baseline) tier = "baseline"; if (invocationCount === thresholds.mid) tier = "mid"; if (invocationCount === thresholds.top) tier = "top"; return { tier, total: items.reduce((sum, item) => sum + item.price, 0) };} for (let call = 1; call <= 8; call += 1) { const report = scoreCart([{ price: call }, { price: 2 }]); console.log(call, report.tier, report.total);}- Line 1 sets the thresholds: baseline at call 6, mid tier at call 30, top tier at call 90.
- Line 2 starts the function in the
"interpreter"tier. - Line 6 adds 1 to
invocationCounton every call. An invocation is a call. - Lines 7 to 9 switch the tier when the count reaches a threshold.
- Lines 14 and 15 call the function 8 times and print the call number, the tier, and the total.
Calls 1 to 5 print interpreter. Call 6 prints 6 baseline 8. The number 6 is only a choice made for this model. What matters is that the tier changes because of what the running code did.
invocationCount. When the count reaches 6, the model sets tier to "baseline". The totals stay the same; only the tier changes.const observedInputs = new Set(); function add(left, right) { observedInputs.add(typeof left + "/" + typeof right); const state = observedInputs.size === 1 ? "stable" : "mixed"; return String(left + right) + " " + state;} console.log(add(2, 3));console.log(add(4, 5));console.log(add("4", 5));This model keeps a Set, a list with no repeats, of the kinds of values add has seen. typeof gives the kind of a value, such as "number" or "string".
- Line 4 saves the pair of kinds, such as
"number/number". - Line 5 says
"stable"if only one pair was ever seen, and"mixed"if more than one was. - Lines 9 and 10 pass two numbers. They print
5 stableand9 stable. - Line 11 passes the string
"4"and the number 5.+joins them into"45". A new pair is seen, so it prints45 mixed.
So the engine watches two different signals:
- Running often (heat) tells it that compiling may pay back.
- Seeing the same kinds of values (feedback) tells it that fast code made just for those kinds is likely to work.
Real engines keep much richer feedback than this Set.
Step through the call-counter model: the counter goes up on each call, and the function moves from the interpreter to baseline code.
script
const thresholds = { baseline: 6, mid: 30, top: 90 };let invocationCount = 0; function scoreCart(items) { invocationCount += 1; if (invocationCount === thresholds.baseline) tier = "baseline"; if (invocationCount === thresholds.mid) tier = "mid"; if (invocationCount === thresholds.top) tier = "top"; return { tier, total: items.reduce((sum, item) => sum + item.price, 0) };} for (let call = 1; call <= 8; call += 1) { const report = scoreCart([{ price: call }, { price: 2 }]); console.log(call, report.tier, report.total);}Counting calls is not enough. One call can run a long loop. So engines also watch how far loops get. In the next model, a number called interruptBudget goes down on every step of the loop. When it reaches 0, the engine knows the loop is hot.
let tier = "interpreter";let interruptBudget = 18; function crunchLoop(limit) { let total = 0; for (let i = 1; i <= limit; i += 1) { interruptBudget -= 3; if (interruptBudget <= 0 && tier === "interpreter") { tier = "baseline"; // model collapses request, compile, install, and OSR entry interruptBudget = 18; } total += i; } return { tier, total };} console.log(crunchLoop(8));- Lines 1 and 2 start in the interpreter with a budget of 18.
- Line 7 takes 3 from the budget on each step of the loop.
- Line 8 checks whether the budget has run out while the loop is still in the interpreter.
- Line 9 switches the tier to
"baseline". Line 10 sets the budget back to 18. - Line 17 calls
crunchLoop(8)once. It prints{ tier: 'baseline', total: 36 }.
The budget reaches 0 on step 6, so the switch happens in the middle of the loop. Moving a running loop into compiled code, without starting the function over, is called on-stack replacement, or OSR. This lesson shows only the idea. A later lesson shows how it works inside the engine.
In a real engine, a used-up budget starts four steps:
- The engine asks for a stronger tier.
- The new code is compiled, maybe on a background thread.
- The engine installs the new code.
- The loop enters the new code at a safe point in the loop.
The replay shows all four as one change of tier. A counter does not rewrite the running code by itself.
Step through a loop with a budget. This is a simple model of moving up a tier and OSR, not a view inside a real engine.
script
let interruptBudget = 18; function crunchLoop(limit) { let total = 0; for (let i = 1; i <= limit; i += 1) { interruptBudget -= 3; if (interruptBudget <= 0 && tier === "interpreter") { tier = "baseline"; // model collapses request, compile, install, and OSR entry interruptBudget = 18; } total += i; } return { tier, total };} console.log(crunchLoop(8));Three plans: never optimize, optimize eagerly, or tier up
PLAYGROUNDThe playground compares three plans on the same cost model:
- Never optimize: every call runs in the interpreter.
- Optimize eagerly: compile every function to the top tier before it runs. "Eagerly" means right away, before knowing whether it is needed.
- Tiered JIT: start in the interpreter, and move a function up only when it crosses a threshold.
Try both programs:
- In the short script, no function runs often enough to pay back a compile. Never optimizing costs the least, and the tiered JIT ties with it.
- In the long session, one function,
format, runs 160 times. The tiered JIT costs the least, because it compiles onlyformat.
const costs = { interpreter: { compile: 0, call: 5 }, baseline: { compile: 15, call: 2 }, mid: { compile: 40, call: 1.2 }, top: { compile: 120, call: 0.6 },}; const program = [ { name: "route", calls: 3 }, { name: "format", calls: 160 }, { name: "validate", calls: 3 }, { name: "paint", calls: 3 }, { name: "cleanup", calls: 3 },]; // A real engine uses richer counters, feedback, and background compilation.// This model only shows the compile-time vs run-time trade-off.console.log("never optimize", 860);console.log("optimize eagerly", 703.2);console.log("tiered JIT", 422.6);860.0compile 0.0 · run 860.0 · functions in the top tier: 0
- interpret every call: 860.0
703.2compile 600.0 · run 103.2 · functions in the top tier: 5
- compile every function: 600.0
- run all calls: 703.2
422.6compile 175.0 · run 247.6 · functions in the top tier: 1
- format -> baseline: 55.0
- format -> mid: 143.0
- format -> top: 335.0
Tiered JIT costs the least for the long session: 175.0 units to compile and 247.6 units to run. This is a simple model. Real engines also use feedback, OSR, background compiles, and their own thresholds.
Optimizing eagerly pays the top-tier compile cost of 120 for all five functions, even the ones called only 3 times. The tiered JIT pays compile costs only for functions that cross a threshold.
In a real program, other things can change which plan wins:
- feedback that keeps changing;
- compiled code that has to be thrown away;
- compiles that have not finished yet;
- limits on memory.
The idea that stays true: spend compile time where the running code shows it will pay back.
Real evidence from Node 22.23.1 and V8 12.4
EXPERIMENTV8 has hidden testing functions whose names start with %. Node lets you use them only when you start it with the flag --allow-natives-syntax. A flag is an option you type after the command. These functions are not part of JavaScript. Without the flag, a browser or Node treats the % as a syntax error.
The lesson tests start separate Node processes to check real facts. First they remove the NODE_TEST_* settings, so the test runner does not change the child process. Then they check only facts that are the same on every run:
- the version numbers;
- which V8 options are on by default;
- status bits from V8 12.4's
runtime.hfile; - lines from
--trace-optthat mention TurboFan compiling.
// Node 22.23.1 (V8 12.4). Run only in Node with --allow-natives-syntax.function hotAdd(x) { let total = 0; for (let i = 0; i < 30; i += 1) total += (x + i) | 0; return total;} %PrepareFunctionForOptimization(hotAdd);hotAdd(1);hotAdd(2);%OptimizeFunctionOnNextCall(hotAdd);hotAdd(3);console.log(%GetOptimizationStatus(hotAdd));- Line 1 says this runs only in Node with
--allow-natives-syntax. - Line 8 tells V8 to start collecting feedback for
hotAdd. - Line 11 asks V8 to optimize
hotAddon its next call. Line 12 makes that call. - Line 13 prints the optimization status, one number made of status bits.
A status bit is a yes-or-no answer packed into one number. In V8 12.4 these bits include:
kIsFunction = 1 << 0: this is a function;kOptimized = 1 << 4: it has optimized code;kTurboFanned = 1 << 6: that code came from TurboFan.
Forcing optimization this way gives the same result every time, so it works in a test. It is still a lab tool. Do not ship code that uses % functions or depends on a certain tier.
node --allow-natives-syntax --trace-opt tier-up-probe.mjsNode 22.23.1 (V8 12.4)
[manually marking ... hotAdd ... for optimization to TURBOFAN]
[compiling method ... hotAdd ... (target TURBOFAN)]
[completed compiling ... hotAdd ... (target TURBOFAN)]
status bits include kIsFunction, kOptimized, and kTurboFannedThe local Node 22.23.1 reports V8 12.4.254.21-node.56. Its options show:
- Sparkplug and TurboFan are on by default.
- Maglev exists as a flag, but it is off by default (
--no-maglev). - The flags for building Maglev code on a background thread are there.
That is why the real optimization proof in this lesson uses TurboFan. The V8 section still teaches Maglev, because Chrome uses it.
Use tiering knowledge safely
PRACTICALTiered compilation explains two things you will meet in real work:
- Why a benchmark (a timing test) needs a warm-up. The first runs are slower while the engine is still moving code up the tiers.
- Why short scripts often never reach a top tier.
It does not mean you should write code for Sparkplug, Maglev, TurboFan, Ion, or FTL. Engine teams change thresholds, tier names, and rules. Your job is to:
- write clear code;
- measure the real thing the user does;
- warm up a benchmark before you time it;
- expect the first few runs to differ from later runs.
Each row of the next table is a situation you may meet. Read across to see what tiering tells you and what to do.
| Situation | What tiering tells you | What to do |
|---|---|---|
| Benchmark warm-up | The first runs are slower while code moves up the tiers. Run the code a few times before timing, then report several timings or the middle one. | Read Measuring performance before you trust a tiny benchmark. |
| Short scripts | A command-line tool's startup, or a page script that runs once, may never reach TurboFan or any top tier. | Make loading and parsing faster before chasing top-tier speed. |
| Hot code | A function called very often, such as one that draws the page or reads data, can climb the tiers. | Measure first. Later lessons on feedback explain why simple, steady inputs help. |
| Code for real users | Do not depend on % functions, tier flags, or one engine's thresholds. | Write JavaScript that runs in every engine, and time what the user actually does. |
If a benchmark changes after warm-up, see Measuring performance. If a hot function behaves differently after its inputs change, the next lessons on type feedback and inline caches explain what the optimizer looks at.
Common misconceptions
"JIT means everything is compiled right away."
No. A tiered JIT skips expensive compiling for cold code on purpose.
"Interpreters are useless, because compiled code is faster."
Interpreters help code start quickly. Running soon can matter more than running at top speed later.
"A function in TurboFan stays there forever."
Optimized code depends on guesses about the values it will see. If a guess turns out wrong, the engine can throw that code away and go back to a slower tier. Later lessons call this deoptimization.
"All engines use V8's tier names."
SpiderMonkey and JavaScriptCore have their own names, thresholds, and inner workings.
"The % functions are a way to speed up real apps."
They are V8 testing tools that need a special flag. Real code should be measured, not forced into a tier.
Each row of the next table is one idea. Read across to see what it means and what it does not mean.
| Idea | What it means | What it does not mean |
|---|---|---|
| JIT | Compiling while the program runs, based on what the code does. | A promise that every function is compiled right away. |
| Interpreter | A tier that starts cold code quickly. | A useless fallback that modern engines avoid. |
| Optimized code | Machine code that depends on guesses about the values it will see. | Code that stays forever and can never be thrown away. |
V8 % functions | Testing tools that need a special flag, for experiments. | JavaScript that works in every engine. |
Practice exercises
5 EXERCISESWhat does this print for 4 calls in the interpreter?
function totalTime(compileCost, callCost, calls) {
return compileCost + callCost * calls;
}
console.log(totalTime(0, 5, 4));console.log(20);The interpreter case costs 0 + 5 × 4, so it prints 20.
In the call-counter model, on which call does the function first move from the interpreter to baseline?
const thresholds = { baseline: 6, mid: 30, top: 90 };
console.log(thresholds.baseline);The model moves to baseline on call 6. Real engines pick their own thresholds.
Which V8 status bit name means the code came from TurboFan?
kTurboFanned. It shows that the forced optimization reached TurboFan, V8's top tier, in this Node build.
What is the short name for moving a running loop into compiled code without starting the function over?
OSR, short for on-stack replacement. It lets a running loop move into compiled code without starting the whole function again.
Production code is the code your real users run. Should it call V8's % optimization functions?
// Should app code call %OptimizeFunctionOnNextCall in production?No. Use % functions only for engine experiments or tests on your own machine. Production code should run in every engine, and you should measure it with timings that matter to users.
Quiz: check your understanding
8 QUESTIONSKeep the cost model, the tier names, and the counters in mind.
Question 1 of 8Why do JavaScript engines use tiered compilation?
Choose an answer to see the explanation.
Question 2 of 8What does this trade-off snippet print first?
Read the code, then predictfunction totalTime(compileCost, callCost, calls) { return compileCost + callCost * calls; } console.log(totalTime(0, 5, 4));Choose an answer to see the explanation.
Question 3 of 8Which V8 tier is the baseline compiler that does not optimize, announced in 2021?
Choose an answer to see the explanation.
Question 4 of 8Which sentence best describes Maglev in V8?
Choose an answer to see the explanation.
Question 5 of 8What makes SpiderMonkey's warm-up counter go up?
Choose an answer to see the explanation.
Question 6 of 8What does this loop-budget model print?
Read the code, then predictlet tier = "interpreter"; let budget = 6; for (let i = 0; i < 3; i += 1) { budget -= 3; if (budget <= 0) tier = "baseline"; } console.log(tier);Choose an answer to see the explanation.
Question 7 of 8Why should production code (the code your real users run) avoid V8's
%functions, such as%GetOptimizationStatus?Choose an answer to see the explanation.
Question 8 of 8Which benchmark habit comes from tiered compilation?
Choose an answer to see the explanation.
Key takeaways
- An interpreter starts quickly. A compiler makes faster code, but costs time first. A JIT compiler decides, while the program runs, what is worth compiling.
- V8's tiers are Ignition, Sparkplug, Maglev, and TurboFan. TurboFan's back end is moving from Sea of Nodes to Turboshaft.
- SpiderMonkey and JavaScriptCore use other names but the same plan: spend more compile time on hotter code.
- Engines spot hot code by counting calls and loop steps, and by feedback about values. OSR lets a running loop switch tiers.
- Warm up benchmarks, expect short scripts to stay in cheap tiers, and never write real app code for one engine tier.
Remember the one-liner.
Tiered compilation means: start cheap, watch the code run, and spend compile time only where it pays back.
Up next: Type feedback & feedback vectors.