On-stack replacement
Learn how V8 switches hot loops into optimized code mid-call, where OSR entries start, and why loop microbenchmarks need care.
- 01Explain why loops need OSRExplain how a function called only once can still hold a loop that runs enough times to need faster code.
- 02Follow an OSR switchPoint to where OSR enters a loop, and name the variable values the faster code must start with.
- 03Time loops carefullySpot when a loop timing shows warm-up, OSR, unused results, inputs that never change, timers, or garbage collection instead of real speed.
Switch while a call is already running
The engine is the program that runs your JavaScript. Chrome and Node use an engine called V8. V8 can run the same function in two ways, called tiers:
- The interpreter runs your code right away. It starts fast, but each step is slow.
- Optimized code is fast code that the computer's processor runs directly. The engine takes time to build it, so it only builds it for code that runs a lot.
Code that runs a lot is called hot. Code that has barely run is called cold. Usually, the engine switches a function to optimized code the next time the function is called.
But some functions are called only once, and then spend a long time in one loop. A data import, an image filter, or a report builder can work like this. If the engine waited for the next call, the work might finish before the fast code ever ran.
On-stack replacement, or OSR, lets the engine switch a function that is already running to faster code. The switch happens in the middle of a loop. The loop's variables keep their current values, so the loop goes on from where it was.
The name comes from two words:
- The stack is the engine's list of function calls that are running right now.
- A frame is one call's entry in that list. It holds the call's variables and where to go back to when the call ends.
OSR replaces the code that a frame is running, while the frame stays on the stack. The function does not start again.
function sumOnce(limit) { let sum = 0; for (let i = 1; i <= limit; i += 1) { sum += i; } return "sum=" + sum;} console.log(sumOnce(5));Line 9 calls sumOnce one time. Lines 3 and 4 are the loop. One pass through a loop is called an iteration. Here the loop runs 5 iterations and prints sum=15. With a much bigger number, the loop could run millions of iterations inside that one call.
The lesson Tiered compilation explains how the engine counts how hot code is. The previous lesson, What optimizing compilers do, explains what the fast code does differently. This lesson is only about the switch in the middle of a loop.
You set off on foot. The walk turns out to be long, so you get on a bicycle halfway. You don't go back home to start again. You keep your bag, and you reach the same place, just sooner.
- In real life: You start by walking
- In JavaScript: The interpreter starts running the function
- In real life: The walk turns out to be long
- In JavaScript: The loop runs enough times to become hot
- In real life: You keep carrying the same bag
- In JavaScript: Variables such as
iandsumkeep their values - In real life: You reach the same place
- In JavaScript: The function gives the same answer after OSR
Hot loops in cold functions
A function and a loop inside it can be hot or cold on their own:
- A function is cold if it has been called only a few times.
- A loop is hot if it has run many iterations.
So one call to a cold function can hold a very hot loop. Counting calls is not enough to spot it. V8 also counts how often each loop goes round.
The jump from the end of a loop back to its top is called the back edge. V8 counts at the back edge. Each time the loop goes round, V8 can ask: "Is this loop hot enough to switch to faster code?"
function longOsrLoop(limit) { let sum = 0; for (let i = 0; i < limit; i += 1) { sum += i & 7; } return sum;} longOsrLoop(10000000);Line 9 calls longOsrLoop once. The loop on lines 3 and 4 runs ten million iterations. On line 4, i & 7 turns i into a small number from 0 to 7. It just gives the loop some cheap work to do.
Node can print a note each time it does OSR. You turn this on with the flag --trace-osr. A flag is an extra word you type after node to change how it runs.
The lesson test runs this code in Node 22, which uses V8 12.4, with --trace-osr. The printed notes contain the word OSR, the function name longOsrLoop, and a line with OSR - entry. With the flag --no-use-osr, which turns OSR off, that entry line is gone. Later V8 versions may word these notes differently.
These notes come from V8, not from the JavaScript language. Your app should never depend on when OSR happens, or on it happening at all.
OSR enters at the top of the loop
Optimized code usually expects to start at the top of the function. The function is called, its variables are set up, and then line 1 runs. OSR can't do that, because the function is already halfway through.
So the engine builds the optimized code with a second way in, called an OSR entry. It is placed at the loop header: the spot at the top of the loop where the next iteration begins.
Before V8 runs your code, it turns it into bytecode: a simple list of small steps that the interpreter runs one by one. You can ask Node to print that list.
$ node --print-bytecode --print-bytecode-filter=longOsrLoop probe.js[generated bytecode for function: longOsrLoop]... JumpLoop ..., [0], ...Line 1 is the command. The flag --print-bytecode-filter=longOsrLoop prints the bytecode for that one function only. Line 3 shows the step that matters, JumpLoop. It is the back edge: the jump from the end of the loop back to its top. The ... stands for numbers that change from run to run, so the test does not check them.
Each time JumpLoop runs, the loop starts its next iteration, so it is a good place to ask "switch to faster code now?" The name JumpLoop belongs to V8 12.4. It is not part of JavaScript, and a future V8 could rename it.
A function can be entered in three ways. Read each row as: "this way in starts here, and is used when this happens."
| Way in | Where the code starts | When it is used |
|---|---|---|
| A normal call | At the top of the function. The variables are set up fresh. | When your code calls the function. |
| An OSR entry | At the top of a loop. The variables already hold their values from the middle of the call. | When a loop that is already running gets hot. |
| The way back after a deopt | In the interpreter, at the position in the bytecode where the fast code stopped. | When a guess made by the fast code turns out wrong in the middle of the function. |
A bicycle is waiting at the next corner. You walk to that corner, get on, and ride on from there. You don't go back to where you started.
- In real life: You start walking down the street
- In JavaScript: The loop starts in the interpreter
- In real life: You reach the next corner
- In JavaScript: The loop reaches its header and checks whether to switch
- In real life: You put your bag on the bicycle
- In JavaScript: The variables go on with the optimized loop
- In real life: You still reach the same place
- In JavaScript: The function gives the same answer
Carry the variables across
Spotting a hot loop is the easy part. The hard part is the switch itself. The fast code must start with the exact values the variables have right now. Say the interpreter has reached i = 5 and sum = 10. The fast code must start with those same two values. If it didn't, the function would give a wrong answer.
The replay below is a small model written in plain JavaScript. It does not look inside a real engine. Instead, a variable called tier says which kind of code is running, and osrBudget says at which iteration the model switches.
Step through a small model of on-stack replacement. It doesn't look inside your engine. It shows why the optimized code must start with the current i and sum.
script
function sumWithOsrModel(limit) { let tier = "interpreter"; let sum = 0; for (let i = 1; i <= limit; i += 1) { if (tier === "interpreter" && i === osrBudget) { tier = "optimized"; console.log("OSR carries i=" + i + ", sum=" + sum); } sum += i; } return { tier, sum };} const report = sumWithOsrModel(8);console.log(report.tier);console.log(report.sum);Step through it:
- Line 15 calls the function once.
- Iterations 1 to 4 run in the interpreter and add up to
sum = 10. - Line 6 is the check at the top of the loop. At
i = 5, it says "switch now". - Line 7 switches
tierto"optimized". - Line 8 prints the values that are carried across:
i=5andsum=10. - Iterations 5 to 8 finish in optimized code, and line 17 prints 36.
i and sum are carried across, so the faster code goes on from iteration 5 instead of starting again.function runTeachingOsr(iterations, budget) { let tier = "interpreter"; let sum = 0; let switchIteration = null; for (let i = 1; i <= iterations; i += 1) { if (tier === "interpreter" && i === budget) { switchIteration = i; tier = "optimized"; } sum += i; } return { switchIteration, tier, sum };}5778i6sum15The optimized code must start with these values.
1:interpreter:12:interpreter:33:interpreter:64:interpreter:105:interpreter:15OSR at iteration 6: carry i=6, sum=156:optimized:217:optimized:288:optimized:369:optimized:4510:optimized:5511:optimized:6612:optimized:78
Changing either number runs the model again. It doesn't look inside your browser's engine.
The model switches at iteration 6 and carries i=6 and sum=15 across. Iterations in the interpreter: 5. Iterations in optimized code: 7. Final sum: 78.
Try other numbers. With a budget of 6 and 12 iterations, the interpreter runs 5 iterations, then optimized code runs 7. If the budget is bigger than the number of iterations, the loop never switches.
Going back to slower code in the middle of a loop
OSR moves a running loop from the interpreter to optimized code. The engine can also move the other way. That is called deoptimization, or deopt for short.
Optimized code is fast because it makes guesses, such as "every value here is a number". Before it uses a value, it runs a quick check called a guard. If the guard fails, the guess was wrong. The engine then goes back to the interpreter, at the exact step where it stopped, with the current values. The lesson Speculation & deoptimization covers guesses and deopt in full. Here we only look at the switch in the middle of a loop.
Step through a small model of a deopt in the middle of a loop. It shows the values the engine must carry back to the interpreter.
script
function sumWithDeoptModel(values) { let mode = "optimized-number-loop"; let total = 0; for (let i = 0; i < values.length; i += 1) { const value = values[i]; if (mode === "optimized-number-loop" && typeof value !== "number") { mode = "interpreter"; console.log("deopt carries i=" + i + ", total=" + total); } total += value; } return total;} This model starts in optimized code that only handles numbers:
- The values 2 and 3 pass, so
totalis 5. - On line 6, the guard sees
null, which is not a number, so the check fails. - Line 7 switches back to the interpreter.
- Line 8 prints the values that are carried back:
i=2andtotal=5. - Line 10 goes on with JavaScript's normal adding rules.
5 + nullis 5, becausenullcounts as 0 here. Then 5 more gives 10.
null fails the number check, the values are carried back to the interpreter, and the answer is still 10.You can see a real deopt in Node too. The code below uses two test-only tools:
%OptimizeFunctionOnNextCall(sumNumbers)on line 11 tells V8 to build optimized code forsumNumbersnow. It only works when Node runs with the flag--allow-natives-syntax.- The flag
--trace-deoptprints a note each time V8 deoptimizes.
function sumNumbers(values) { let total = 0; for (let i = 0; i < values.length; i += 1) { total += values[i]; } return total;} const numbers = [1, 2, 3, 4];for (let i = 0; i < 100000; i += 1) sumNumbers(numbers);%OptimizeFunctionOnNextCall(sumNumbers);sumNumbers(numbers);sumNumbers([1, "2", 3, 4]);Line 10 calls sumNumbers 100,000 times with numbers only. Line 11 asks for optimized code. Line 13 passes the text "2" instead of a number. A check inside the loop fails, and V8 deoptimizes.
[bailout (kind: deopt-eager, reason: ...): begin. deoptimizing ... <JSFunction sumNumbers ...>, ... bytecode offset ...]The real note is long and noisy. The test only checks three parts of it:
deoptimizing: V8 is going back to slower code.sumNumbers: the function it happened in.bytecode offset: the position in the bytecode list where the interpreter picks up again.
Memory addresses, ID numbers, and timing change from run to run, so the test ignores them. The wording may also change in later V8 versions.
What the V8 tests really show
The tests for this lesson run on Node 22, which uses V8 12.4. They check that process.version starts with v22. and that process.versions.v8 starts with 12.4.. They don't check exact version numbers, memory addresses, bytecode positions, or times, because those change from run to run.
--trace-osrcan print an OSR entry for a long loop in a function that was called once.--no-use-osrturns OSR off, so the same code prints no OSR entry.--print-bytecode --print-bytecode-filter=longOsrLoopprints aJumpLoopstep for the loop's back edge.--trace-deoptcan print a deopt note forsumNumbersthat mentions abytecode offset.
These are facts about V8 12.4, not rules of the JavaScript language. Other engines, like the ones in Firefox and Safari, switch code in their own ways. The JavaScript rules only promise one thing: the same code gives the same result.
Why timing a small loop can fool you
A benchmark is code that runs a piece of work many times to measure how fast it is. A small benchmark that times one loop often measures the engine's switching instead of your code. Here is what can go wrong:
- The first iterations run in the slow interpreter. Later ones may run in optimized code after OSR.
- If nothing uses the result, the optimized code may skip or change the work.
- If the input never changes, the work may be easier than it is in the real app.
- Garbage collection, the engine pausing to free memory that is no longer used, can add random delays.
- Timers can't measure very short times well.
function cartTotal(items) { let total = 0; for (let i = 0; i < items.length; i += 1) { total += items[i]; } return total;} const basket = [10, 20, 30];for (let sample = 0; sample < 1000; sample += 1) { cartTotal(basket);}console.log("bad benchmark kept result:", false);Line 9 makes one basket of prices. Lines 10 to 12 call cartTotal 1,000 times. The bug is on line 11: the total that cartTotal returns is thrown away. Line 13 prints false to say so. The example prints this instead of a time, because times differ on every computer, so a test can't check them.
function cartTotal(items) { let total = 0; for (let i = 0; i < items.length; i += 1) { total += items[i]; } return total;} const baskets = [[10, 20, 30], [5, 15, 25], [7, 11, 13]];let sink = 0;for (let warmup = 0; warmup < 100; warmup += 1) { sink += cartTotal(baskets[warmup % baskets.length]);}for (let sample = 0; sample < 1000; sample += 1) { sink += cartTotal(baskets[sample % baskets.length]);}console.log("better benchmark kept result:", sink > 0);console.log("sample total:", cartTotal(baskets[1]));This version fixes the bad one:
- Line 9 makes three different baskets, so the input changes.
- Line 10 makes
sink, a variable that collects every result. Because the results are used, the engine must really do the work. - Lines 11 and 12 warm up: they run the work 100 times first, so the switching is over before you measure.
- Lines 14 and 15 are the runs you would time.
- Line 17 prints
true, and line 18 prints45, the total of the second basket.
sink, so the work must really happen.This is still only an outline. A real benchmark also needs a timer, many runs, and the middle value of those runs. For the full steps, see Measuring performance.
Each row of this table is one trap. Read it as: "this goes wrong, so do this instead."
| Trap | What goes wrong | What to do instead |
|---|---|---|
| Warm-up and OSR | The first iterations may run in slower code than the later ones. | Warm up with real-looking work first, then time many runs. |
| Unused result | If nothing uses the result, the fast code may skip or change the work. | Keep the result in a variable like sink, or check it. |
| Inputs that never change | The same tiny input every time can make the work easier than in the real app. | Use different inputs that look like real data. |
| Timers and garbage collection | A timer can't measure very short work well, and garbage collection can pause the run. | Time enough work, report the middle value of many runs, and watch memory use. |
- Optimized code starts at the top of the loop, with the variables' current values.
- A timing loop calls a function but never uses the total it returns.
- The loop must return the same
sumbefore and after the switch. - V8's bytecode shows a
JumpLoopstep at the end of the loop. - Every run uses the same tiny limit and the same input array.
- When a check fails, the engine goes back to the interpreter and keeps running correctly.
Put each card in its group: how OSR works, a benchmark trap, or a promise the JavaScript rules make.
What to do with this in real work
You almost never need to cause OSR or check for it. What helps is knowing three things:
- Why benchmarks need a warm-up.
- Why one long loop can speed up while it is running.
- Why you should trust a measurement more than a rule of thumb.
If a loop really is slow, keep the code easy to read first. Then measure it with real data. Use this checklist when you time code:
- Warm up with the same kinds of values the measured runs will use.
- Use the result, so the engine must really do the work you want to measure.
- Change the inputs so they look like real data, such as different sizes and shapes.
- Run many times and report the middle value, not one surprising number.
- Don't ship V8 flags, test-only commands like
%OptimizeFunctionOnNextCall, or code that counts on when OSR happens.
Measure what the user actually waits for first. Knowing how the engine works helps you explain a measurement. It is not a list of rules for how to write code.
Common misconceptions
- "OSR is a JavaScript feature." It is something the engine does on its own. Your code can't call it.
- "A function must be called many times before it gets faster." A loop can get hot inside one call. That is exactly when OSR helps.
- "OSR starts the loop again." The loop goes on from its top, with the variables keeping their current values.
- "A deopt is a crash." It is a safe way back to slower code, and the answer stays right.
- "Timing a small loop shows how fast my app is." It may mostly show warm-up, OSR, timer limits, or skipped work.
The table below compares four words that are easy to mix up. Read each row as: "this word means this, and it is not that."
| Word | What it really means | What it is not |
|---|---|---|
| OSR | The engine switching a running function to other code in the middle of a loop. | A JavaScript feature your code can call. |
| Hot loop | A loop that has run many iterations, even if its function was called once. | A function that has been called many times. |
| Deopt | A safe way back from fast code to the interpreter when a guess was wrong. | A crash, or a change in the answer. |
| Loop benchmark | A small timing test that may mostly show the engine switching code. | Proof that one way of writing code is faster in your app. |
Practice exercises
Read the starter code. What number does it print?
function sumOnce(limit) {
let sum = 0;
for (let i = 1; i <= limit; i += 1) sum += i;
return sum;
}
console.log(sumOnce(6));The loop adds 1 + 2 + 3 + 4 + 5 + 6, so it prints 21.
In the replay model, the loop runs 8 iterations and the budget is 5. Which iteration is the first to run in optimized code?
Iteration 5 is the first one in optimized code. The model carries i = 5 and sum = 10 across.
Run the starter code, or work it out in your head. How many iterations run with tier set to "optimized"?
function countTiered(iterations, switchAt) {
let tier = "interpreter";
let optimized = 0;
for (let i = 1; i <= iterations; i += 1) {
if (i === switchAt) tier = "optimized";
if (tier === "optimized") optimized += 1;
}
return optimized;
}
console.log(countTiered(8, 5));function countTiered(iterations, switchAt) {
let tier = "interpreter";
let optimized = 0;
for (let i = 1; i <= iterations; i += 1) {
if (i === switchAt) tier = "optimized";
if (tier === "optimized") optimized += 1;
}
return optimized;
}
console.log(countTiered(8, 5));Iterations 5 to 8 run with tier set to "optimized", so the function prints 4.
What is the biggest problem in the bad benchmark?
The benchmark never uses the total that cartTotal returns. The engine may skip or change that work, so the timing would not measure the real adding.
Your team is timing the product filter on a shopping site. Name one habit from this lesson that makes the timing more trustworthy.
Good answers: warm up with real-looking data, keep the result in a sink, change the inputs, report the middle value of many runs, or measure what the user waits for first.
Which phrase in the V8 deopt note tells you where the interpreter picks up again?
The phrase is bytecode offset. It is the position in the bytecode list where the interpreter goes on.
Check your understanding
Question 1 of 7Why does a function that is called only once still need OSR?
Choose an answer to see the explanation.
Question 2 of 7What does this loop print?
Read the code, then predictfunction sumOnce(limit) { let sum = 0; for (let i = 1; i <= limit; i += 1) sum += i; return sum; } console.log(sumOnce(5));Choose an answer to see the explanation.
Question 3 of 7What is special about an OSR entry?
Choose an answer to see the explanation.
Question 4 of 7What does this model print last?
Read the code, then predictlet tier = "interpreter"; let sum = 0; for (let i = 1; i <= 4; i += 1) { if (i === 3) tier = "optimized"; sum += i; } console.log(tier, sum);Choose an answer to see the explanation.
Question 5 of 7What must a deopt keep, so the function can go on?
Choose an answer to see the explanation.
Question 6 of 7Which benchmark can you trust more?
Choose an answer to see the explanation.
Question 7 of 7Which V8 12.4 bytecode step does the lesson test look for?
Choose an answer to see the explanation.
Key takeaways
- OSR lets a loop that is already running switch to optimized code, without waiting for the next call.
- The switch happens at the top of the loop. Variables such as
iandsumkeep their current values. - A deopt goes the other way. When a guess fails in the middle of a loop, the engine goes back to the interpreter at the same step.
- Timing a small loop can fool you. Warm-up, OSR, unused results, inputs that never change, garbage collection, and timers can all get in the way.
- Use what you know about OSR to explain measurements. Don't write code that counts on one engine's timing.
Remember the one-liner.
OSR switches a running loop to faster code in the middle, and the loop keeps its values and its answer.
Up next: Tagged values, Smis & heap numbers. You'll see how engines store JavaScript values in the computer's memory.