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 OSRDescribe how a function called once can still become hot because a loop back edge keeps running inside that live call.
- 02Read an OSR handoffIdentify loop headers, OSR entry points, and the live values an optimized frame must receive when it takes over.
- 03Benchmark 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.
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.
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.
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
iandsumare 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.
function longOsrLoop(limit) { let sum = 0; for (let i = 0; i < limit; i += 1) { sum += i & 7; } return sum;} longOsrLoop(10000000);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.
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.
| Point | Where it starts | Why it exists |
|---|---|---|
| Normal function entry | Starts at the top of the function with fresh parameters and locals. | Used when a function is called normally. |
| OSR entry | Starts at a loop header with live locals already holding mid-function values. | Used when a currently running loop becomes hot. |
| Deopt continuation | Rebuilds an interpreter frame at a bytecode offset from optimized state. | Used when optimized assumptions fail mid-function. |
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.
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.
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 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.
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 };}5778615The optimized loop must receive these live values before it can continue.
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 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.
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.
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.
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;} 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.
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]);[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.
--trace-osrcan show an OSR entry for a long-running loop in a function called once.--no-use-osrremoves that OSR entry line for the same probe.--print-bytecode --print-bytecode-filter=longOsrLoopprints aJumpLoopback edge.--trace-deoptcan show a deopt line forsumNumberswith abytecode 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.
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.
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.
| Trap | What goes wrong | Safer habit |
|---|---|---|
| Warm-up and OSR | Early loop iterations may run in a different tier from later iterations. | Warm up representative work, then measure repeated samples. |
| Unused result | If the result is ignored, an optimizer may remove or reshape the work. | Store the result in a visible sink or assert it. |
| Fixed inputs | A constant limit or constant array can invite constant folding or unrealistic specialization. | Use realistic, varied inputs from the workload. |
| Timer and GC noise | Short loops can be smaller than timer resolution or interrupted by garbage collection. | Measure enough work, report medians or distributions, and watch allocations. |
- 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
sumbefore and after the tier switch. - V8 bytecode shows a
JumpLoopinstruction 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.
Place each card where it belongs: an OSR detail, a benchmark pitfall, or the JavaScript semantic guarantee.
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.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| OSR | An implementation strategy for tier switching inside a live stack frame. | A JavaScript feature you can call directly. |
| Hot loop | A loop back edge that runs many times, even if the function was called once. | The same thing as a hot function call count. |
| Deopt | A safe return from optimized assumptions to interpreter-compatible state. | A crash or a change in JavaScript semantics. |
| Loop benchmark | A tiny experiment that may show tiering artifacts. | Proof that one coding style wins in your app. |
Practice exercises
Read the starter code. What value 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 teaching model, the limit is 8 and the budget is 5. Which iteration starts optimized?
Iteration 5 is the first optimized iteration. The model carries i = 5 and sum = 10 into the optimized loop.
Run or reason through the starter code. How many iterations run in the optimized tier?
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));The optimized tier runs for iterations 5 through 8, so the function prints 4.
Name the biggest problem in the bad micro-benchmark.
The benchmark ignores the returned total, so it risks measuring optimized-away or reshaped work instead of the real calculation.
Your team is measuring a product-list filter on a shopping site. Name one habit from this lesson that makes the benchmark safer.
Good answers include warming up representative work, keeping a result sink, varying inputs, measuring medians, and measuring the real user-visible path first.
Which stable phrase in the V8 deopt trace tells you the continuation position?
The stable phrase is bytecode offset. It names the point where rebuilt interpreter state can continue.
Check your understanding
Question 1 of 7Why does a function called 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 point?
Choose an answer to see the explanation.
Question 4 of 7What does this teaching 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 does deoptimization need to preserve?
Choose an answer to see the explanation.
Question 6 of 7Which benchmark is safer?
Choose an answer to see the explanation.
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
iandsumcan 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.