cf.completefrontendCode editorOpen lab
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 OSRDescribe how a function called once can still become hot because a loop back edge keeps running inside that live call.
  • 02
    Read an OSR handoffIdentify loop headers, OSR entry points, and the live values an optimized frame must receive when it takes over.
  • 03
    Benchmark loops carefullyAvoid mistaking warm-up, OSR-only entry paths, dead results, fixed inputs, timers, and GC noise for real application speed.

Switch while a call is already running

A normal optimized function starts when you call it. But a loop can become hot inside a function that was called only once. If the engine waited for the next call, a data import, parser, image filter, or report generator might finish before optimized code ever gets a chance to run.

Definition

On-stack replacement, usually shortened to OSR, is an engine technique that moves a currently running function frame from one tier of code to another at a safe point, usually a loop header, while preserving the live local values.

The “stack” is the engine's record of active function calls. A frame is one call's portion of that stack: its parameters, local variables, return point, and bookkeeping. OSR replaces the code used by that live frame without asking JavaScript to start the function again.

A function called once can still spend most of its life in a loopPop 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 once. Lines 3 and 4 can still run many times. That difference is why this lesson links back to tiered compilation for hotness counters, then zooms in on the special loop handoff. The previous lesson, What optimizing compilers do, explains transformations such as loop-invariant code motion. The next lesson, Tagged values, Smis & heap numbers, explains the values those optimized frames move around.

Real-life analogySwitching to a bicycle during a long walk

On a long walk, you can switch to a bicycle halfway through without restarting the trip. You keep your bag and continue toward the same place.

In real life: You start by walking
In JavaScript: The interpreter or baseline tier starts the function
In real life: The walk becomes long
In JavaScript: The loop becomes hot enough for optimized code
In real life: You keep carrying the same bag
In JavaScript: Live variables such as i and sum are carried across
In real life: You reach the same place
In JavaScript: JavaScript produces the same result after OSR

Where the analogy stops: People choose when to ride. OSR is an internal runtime decision and must be invisible to JavaScript code.

Hot loops in cold functions

A function is cold when it has not been called enough to justify expensive compilation. A loop is hot when its back edge runs many times. Those facts can disagree: one call can contain ten million loop iterations.

Engines already use hotness counters, but function-entry counters are not enough for this shape. V8 also tracks loop progress. The bytecode for a loop has a back-edge instruction named JumpLoop. Around that back edge, V8 can update OSR urgency and decide whether an optimized loop entry should be compiled.

Node-only probe that V8 can OSR during a single callJavaScript
function longOsrLoop(limit) {  let sum = 0;  for (let i = 0; i < limit; i += 1) {    sum += i & 7;  }  return sum;} longOsrLoop(10000000);
Verified runtime fact

The lesson test runs this code in Node 22 with --trace-osr. It asserts stable facts only: the output mentions OSR, the function name longOsrLoop, and an OSR - entry line. With --no-use-osr, that entry line disappears.

This is not JavaScript syntax. It is V8 evidence. Your application should never depend on the exact threshold or on OSR happening at all.

OSR entry points start at loop headers

Optimized code normally expects to start with a fresh call: parameters arrive, locals are initialized, then line 1 runs. OSR needs a different doorway. The loop is already partway through the function, so the optimized code is compiled with a special entry point at a loop header. A loop header is the place where the next iteration begins.

The stable bytecode clue is JumpLoopShell
$ node --print-bytecode --print-bytecode-filter=longOsrLoop probe.js[generated bytecode for function: longOsrLoop]... JumpLoop ..., [0], ...

V8's full printed bytecode includes offsets and addresses that change between runs, so the test does not pin them. The stable clue is the word JumpLoop. That back edge is the loop's “next lap” point, and it is a natural place to ask whether the loop should continue in optimized code.

Three entry and continuation points
PointWhere it startsWhy it exists
Normal function entryStarts at the top of the function with fresh parameters and locals.Used when a function is called normally.
OSR entryStarts at a loop header with live locals already holding mid-function values.Used when a currently running loop becomes hot.
Deopt continuationRebuilds an interpreter frame at a bytecode offset from optimized state.Used when optimized assumptions fail mid-function.
Real-life analogyPicking up a bicycle at the next corner

On a long walk, a bicycle at the next corner lets you continue faster. You do not restart the trip; you keep moving from where you are.

In real life: You start walking down the street
In JavaScript: The loop begins in interpreter or baseline code
In real life: You reach the next corner
In JavaScript: The loop header reaches an OSR check
In real life: You keep your bag on the bicycle
In JavaScript: Live variables continue with the optimized loop
In real life: You still reach the same place
In JavaScript: The JavaScript result stays the same

Where the analogy stops: People choose how to travel. An engine uses heuristics and may decide the switch is not worth it.

Carry live values across the handoff

The hard part is not deciding that a loop is hot. The hard part is entering compiled code with the right live values. If the interpreter has already computed i = 5 and sum = 10, the optimized loop must receive exactly those values. Otherwise the language result would change.

OSR walkthrough: switch at the loop header
Step 0 of 10Ready
Your turn: follow the blue line

Step through a teaching model of on-stack replacement. It is not inspecting your engine; it shows why a loop entry must receive the live i and sum values.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. 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 the model. Line 15 makes one call. The first four iterations build sum = 10. Line 6 is the budget check at the loop header. Line 7 switches the tier, and line 8 prints the values that must be carried into the optimized loop.

Playground: choose the OSR budget
Teaching-model OSR loopJavaScript
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 };}
Run summaryswitch at 6
interpreter iterations5
optimized iterations7
final sum78
carried `i`6
carried `sum`15

The optimized loop must receive these live values before it can continue.

  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 12OSR happened

Changing either value runs the teaching model again. It does not inspect your browser engine.

The teaching model switches at iteration 6, carrying i=6 and sum=15. Interpreter iterations: 5. Optimized iterations: 7. Final sum: 78.

This is a teaching model of a loop back-edge budget. Real V8 uses internal interrupt budgets, urgency, and compiled OSR entries.

Change the budget and the iteration count. When the budget is 6 and the limit is 12, the interpreter runs five trips, then optimized code runs seven trips. When the budget is larger than the loop, there is no handoff.

Deoptimizing mid-function goes the other way

OSR moves from a less specialized tier into optimized code. Deoptimization moves the other way: optimized code gives up an assumption and rebuilds an interpreter-compatible frame at the right bytecode offset. The wider deoptimization story belongs to the speculation and deoptimization lesson; here we focus on the mid-function handoff.

Deopt walkthrough: rebuild interpreter state
Step 0 of 9Ready
Your turn: follow the blue line

Step through a teaching model of deoptimizing in the middle of a loop. It shows the values an engine must carry back to interpreter code.

Running in
  1. script
Next: line 15
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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;} 
CallStoreChangeResultRun = next line. 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.

The teaching model starts with a number-only loop. The value null fails the guard on line 6. Line 7 switches to the interpreter model, line 8 carries the live values, and line 10 finishes with JavaScript's ordinary addition rules. The answer is still 10.

Node-only deopt probe with a bytecode offsetJavaScript
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]);
Stable deopt trace shape asserted by the testText
[bailout (kind: deopt-eager, reason: ...): begin. deoptimizing ... <JSFunction sumNumbers ...>, ... bytecode offset ...]

The real trace is noisy, so the test only checks stable substrings: deoptimizing, sumNumbers, and bytecode offset. Addresses, optimization IDs, and exact timing are intentionally ignored.

Real V8 evidence, carefully stated

The tests for this lesson run on Node 22, which embeds V8 12.4. They check process.version with a v22. prefix and process.versions.v8 with a 12.4. prefix. They do not pin patch versions, memory addresses, instruction offsets, or timing numbers.

What the probes prove
  • --trace-osr can show an OSR entry for a long-running loop in a function called once.
  • --no-use-osr removes that OSR entry line for the same probe.
  • --print-bytecode --print-bytecode-filter=longOsrLoop prints a JumpLoop back edge.
  • --trace-deopt can show a deopt line for sumNumbers with a bytecode offset.

Those are V8 12.4 implementation facts, not ECMAScript rules. Other engines have their own tiering and OSR machinery. The JavaScript specification only requires the same observable result.

Why loop benchmarks mislead

A tiny loop benchmark often measures tiering artifacts. Early iterations may be interpreter code. Later iterations may enter through an OSR path that normal calls do not use. Fixed inputs may make the work easier than production. If the result is unused, the optimizer may remove or reshape the work. Garbage collection and timer resolution can drown out the signal.

Bad micro-benchmark: the result is ignoredPop 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);

This bad sample prints a stable warning value, not a timing number, because timing is not portable enough for a lesson test. The important bug is line 10: the returned total is thrown away.

Better benchmark skeleton: warm up, vary inputs, keep a sinkPop 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 is still only a skeleton. A real benchmark would add a timer, many samples, medians or distributions, and allocation checks. For the full workflow, use Measuring performance.

Loop benchmark traps
TrapWhat goes wrongSafer habit
Warm-up and OSREarly loop iterations may run in a different tier from later iterations.Warm up representative work, then measure repeated samples.
Unused resultIf the result is ignored, an optimizer may remove or reshape the work.Store the result in a visible sink or assert it.
Fixed inputsA constant limit or constant array can invite constant folding or unrealistic specialization.Use realistic, varied inputs from the workload.
Timer and GC noiseShort loops can be smaller than timer resolution or interrupted by garbage collection.Measure enough work, report medians or distributions, and watch allocations.
Sort the OSR and benchmark clues
  • Optimized code starts at a loop header and receives live locals.
  • A timing loop calls a function but never uses the returned total.
  • The loop must return the same sum before and after the tier switch.
  • V8 bytecode shows a JumpLoop instruction for the back edge.
  • Every sample uses the same tiny constant limit and input array.
  • When a guard fails, the engine rebuilds enough interpreter state to keep running correctly.
Try it yourself
0 of 6 correct

Place each card where it belongs: an OSR detail, a benchmark pitfall, or the JavaScript semantic guarantee.

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

What working developers should do

You usually do not need to force or detect OSR. You need to know why warm-up exists, why a single long loop can change behavior during a run, and why a profile beats folklore. If a measured hot loop matters, keep the code readable first, then test realistic inputs and watch allocations.

  • Warm up benchmarks with the same kinds of values the measured run will use.
  • Use the computed result so the engine must preserve the work you meant to measure.
  • Vary inputs enough to resemble the real workload, especially limits and data shapes.
  • Report repeated samples, not one dramatic timing number.
  • Do not ship V8 flags, native syntax, or code that depends on OSR thresholds.

The practical rule is simple: measure the user-visible path first. Engine internals help you explain a profile; they should not become a style guide on their own.

Common misconceptions

  • “OSR is a JavaScript feature.” It is an engine implementation detail. Your code cannot call it directly.
  • “A function must be called many times to optimize.” A loop can be hot inside one call, which is the OSR case.
  • “OSR means the loop starts over.” It continues from a loop header with live values carried across.
  • “Deopt is a crash.” It is a safe recovery path that preserves JavaScript semantics.
  • “A loop microbenchmark proves production speed.” It may mostly prove warm-up, OSR, timer, or dead-result behavior.
Similar ideas that are easy to mix up
IdeaWhat it meansDo not confuse it with
OSRAn implementation strategy for tier switching inside a live stack frame.A JavaScript feature you can call directly.
Hot loopA loop back edge that runs many times, even if the function was called once.The same thing as a hot function call count.
DeoptA safe return from optimized assumptions to interpreter-compatible state.A crash or a change in JavaScript semantics.
Loop benchmarkA tiny experiment that may show tiering artifacts.Proof that one coding style wins in your app.

Practice exercises

Exercise 1 · Warm-upPredict the loop sum

Read the starter code. What value 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 the OSR switch point

    In the teaching model, the limit is 8 and the budget is 5. Which iteration starts optimized?

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

      Exercise 3 · PracticeWrite a tiny tier counter

      Run or reason through the starter code. How many iterations run in the optimized tier?

      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

        Name the biggest problem in the bad micro-benchmark.

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

          Exercise 5 · ChallengeApply it to a real site

          Your team is measuring a product-list filter on a shopping site. Name one habit from this lesson that makes the benchmark safer.

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

            Exercise 6 · ChallengeRead a deopt trace clue

            Which stable phrase in the V8 deopt trace tells you the continuation position?

            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 called 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 point?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does this teaching 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 does deoptimization need to preserve?

                Choose an answer to see the explanation.

              6. Question 6 of 7Which benchmark is safer?

                Choose an answer to see the explanation.

              7. Question 7 of 7What stable V8 bytecode clue does the lesson test look for?

                Choose an answer to see the explanation.

              Key takeaways

              • OSR lets a currently running loop enter optimized code without waiting for the next function call.
              • The special entry point is usually at a loop header, where live values such as i and sum can be carried across.
              • Deoptimization can rebuild interpreter-compatible state at a bytecode offset when optimized assumptions fail mid-function.
              • Loop microbenchmarks are fragile because warm-up, OSR entry paths, unused results, fixed inputs, GC, and timers can dominate.
              • Use OSR knowledge to interpret measurements, not to write code that depends on one engine's thresholds.

              Remember the one-liner.
              OSR is the engine changing the train engine mid-journey: the loop keeps its passengers, route, and final destination.

              Up next: Tagged values, Smis & heap numbers, where you will see how engines fit JavaScript values into machine words.

              CompleteFrontend Clear concepts. Working examples.