completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

On-stack replacement

Learn how V8 switches hot loops into optimized code mid-call, where OSR entries start, and why loop microbenchmarks need care.

By the end, you can
  • 01
    Explain why loops need OSRExplain how a function called only once can still hold a loop that runs enough times to need faster code.
  • 02
    Follow an OSR switchPoint to where OSR enters a loop, and name the variable values the faster code must start with.
  • 03
    Time 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.

The main idea

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.

One call, with a loop insidePop out in the code editor (opens in a new tab)JavaScript
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.

Real-life analogySwitching to a bicycle during a long walk

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 i and sum keep 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?"

Node-only test code: one call, a very long loopJavaScript
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.

longOsrLoop(10000000) is called 1 time. Inside that one call, the loop runs 10,000,000 iterations.longOsrLoop(10000000);1 callcalled onceinside it10,000,000loop iterationsso the loop gets hot
The function is called only once, so it looks cold. The loop inside it runs ten million times, so the loop gets hot.

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.

Checked by the lesson test

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.

Print the bytecode and find JumpLoop (V8 12.4)Shell
$ 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."

Three ways into a function's code
Way inWhere the code startsWhen it is used
A normal callAt the top of the function. The variables are set up fresh.When your code calls the function.
An OSR entryAt 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 deoptIn 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.
Real-life analogyPicking up a bicycle at the next corner

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: switch at the top of the loop
Step 0 of 10Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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 tier to "optimized".
  • Line 8 prints the values that are carried across: i=5 and sum=10.
  • Iterations 5 to 8 finish in optimized code, and line 17 prints 36.
sumWithOsrModel(8). The interpreter runs iterations 1 to 4, and sum reaches 10. A cart called carried holds i: 5 and sum: 10. Optimized code runs iterations 5 to 8, and sum ends at 36.sumWithOsrModel(8)iterations 1-4interpretersum reaches 10i: 5sum: 10carriediterations 5-8optimizedsum ends at 36
The switch happens in the middle of the loop. The values of i and sum are carried across, so the faster code goes on from iteration 5 instead of starting again.
Playground: choose when the loop switches
Model of a loop that switchesJavaScript
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 };}
Resultswitches at 6
iterations in the interpreter5
iterations in optimized code7
final sum78
carried i6
carried sum15

The optimized code must start with these values.

  1. 1:interpreter:1
  2. 2:interpreter:3
  3. 3:interpreter:6
  4. 4:interpreter:10
  5. 5:interpreter:15
  6. OSR at iteration 6: carry i=6, sum=15
  7. 6:optimized:21
  8. 7:optimized:28
  9. 8:optimized:36
  10. 9:optimized:45
  11. 10:optimized:55
  12. 11:optimized:66
  13. 12:optimized:78
Step 7 of 12Switched

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.

A small model of the switch. The budget is the iteration where it switches. Real V8 uses its own counters to decide.

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: go back to the interpreter
Step 0 of 9Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 15
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;} 
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

This model starts in optimized code that only handles numbers:

  • The values 2 and 3 pass, so total is 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=2 and total=5.
  • Line 10 goes on with JavaScript's normal adding rules. 5 + null is 5, because null counts as 0 here. Then 5 more gives 10.
sumWithDeoptModel([2, 3, null, 5]). Optimized code adds 2 and 3, so total is 5. Then null fails the number check. A cart called carried holds i: 2 and total: 5. The interpreter finishes the loop, and total ends at 10.sumWithDeoptModel([2, 3, null, 5])adds 2 and 3optimizedtotal is 5null fails the checki: 2total: 5carriedadds null and 5interpretertotal ends at 10
This is the same handoff, going the other way. When 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 for sumNumbers now. It only works when Node runs with the flag --allow-natives-syntax.
  • The flag --trace-deopt prints a note each time V8 deoptimizes.
Node-only test code that makes V8 deoptimizeJavaScript
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.

What the deopt note looks like (V8 12.4, shortened)Text
[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.

What the tests show
  • --trace-osr can print an OSR entry for a long loop in a function that was called once.
  • --no-use-osr turns OSR off, so the same code prints no OSR entry.
  • --print-bytecode --print-bytecode-filter=longOsrLoop prints a JumpLoop step for the loop's back edge.
  • --trace-deopt can print a deopt note for sumNumbers that mentions a bytecode 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.
A bad benchmark: the result is never usedPop out in the code editor (opens in a new tab)JavaScript
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.

A better benchmark: warm up, change the inputs, keep the resultPop out in the code editor (opens in a new tab)JavaScript
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 prints 45, the total of the second basket.
Left: cartTotal(basket) gives 60, and the 60 is thrown away. Right: sink += cartTotal(...) puts the 60 into a box called sink, so the result is kept.cartTotal(basket);60the totalthrown awaysink += cartTotal(…);60sinkresult kept
Both lines do the same adding. On the left, nothing uses the 60, so the engine may skip the work. On the right, the total goes into 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."

Traps when timing a small loop
TrapWhat goes wrongWhat to do instead
Warm-up and OSRThe first iterations may run in slower code than the later ones.Warm up with real-looking work first, then time many runs.
Unused resultIf 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 changeThe 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 collectionA 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.
Sort the OSR and benchmark cards
  • 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 sum before and after the switch.
  • V8's bytecode shows a JumpLoop step 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.
Try it yourself
0 of 6 correct

Put each card in its group: how OSR works, a benchmark trap, or a promise the JavaScript rules make.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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."

Four words that are easy to mix up
WordWhat it really meansWhat it is not
OSRThe engine switching a running function to other code in the middle of a loop.A JavaScript feature your code can call.
Hot loopA loop that has run many iterations, even if its function was called once.A function that has been called many times.
DeoptA safe way back from fast code to the interpreter when a guess was wrong.A crash, or a change in the answer.
Loop benchmarkA 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

Exercise 1 · Warm-upPredict the loop sum

Read the starter code. What number does it print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
function sumOnce(limit) {
  let sum = 0;
  for (let i = 1; i <= limit; i += 1) sum += i;
  return sum;
}
console.log(sumOnce(6));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind where the switch happens

    In the replay model, the loop runs 8 iterations and the budget is 5. Which iteration is the first to run in optimized code?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeCount the fast iterations

      Run the starter code, or work it out in your head. How many iterations run with tier set to "optimized"?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the benchmark bug

        What is the biggest problem in the bad benchmark?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeUse it on a real site

          Your team is timing the product filter on a shopping site. Name one habit from this lesson that makes the timing more trustworthy.

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeRead a clue in the deopt note

            Which phrase in the V8 deopt note tells you where the interpreter picks up again?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              On-stack replacement quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Why does a function that is called only once still need OSR?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this loop print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function 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.

              3. Question 3 of 7What is special about an OSR entry?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does this model print last?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                let 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.

              5. Question 5 of 7What must a deopt keep, so the function can go on?

                Choose an answer to see the explanation.

              6. Question 6 of 7Which benchmark can you trust more?

                Choose an answer to see the explanation.

              7. 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 i and sum keep 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.

              CompleteFrontend Clear concepts. Working examples.