Speculation & deoptimization
Learn how JavaScript engines optimize for observed types, guard those assumptions, and deopt safely when runtime values change.
- 01Explain speculative optimizationDescribe how feedback lets an optimizer compile the likely case while keeping a fallback for JavaScript's dynamic behavior.
- 02Read deopt triggersSeparate inline guards and checks from watchpoint-style dependencies, then connect them to eager and lazy deoptimization.
- 03Investigate without overfittingUse
--trace-deoptcarefully, recognize deopt loops, and turn the lesson into stable, measured production habits.
Optimize the likely case, keep an exit
JavaScript engines want hot code to run like carefully planned machine code, but JavaScript values can change shape and type at runtime. The compromise is speculation: compile the case that has happened so far, then keep a safe exit for the case that happens next.
Speculative optimization means an engine compiles optimized code using runtime feedback, guards the assumptions it made, and deoptimizes when an assumption stops being true. Deoptimization reconstructs a less-specialized frame so JavaScript still produces the correct result.
This lesson builds on type feedback and inline caches. Those lessons explain how engines collect evidence; here you use that evidence to understand why optimized code needs guards, watchpoints, and deopt metadata. The previous lesson, Optimizing compilers, explains the compilers that emit the optimized code.
Keep your favorite snack on the top shelf because you choose it often. When a guest asks for something different, go back to the recipe instead of forcing the usual choice.
- In real life: You often pick the same snack
- In JavaScript: Feedback says the hot path has familiar inputs
- In real life: You keep it on the top shelf
- In JavaScript: The optimizer emits a fast path for the likely case
- In real life: A guest asks for something different
- In JavaScript: A guard fails because the value no longer matches the speculation
- In real life: You go back to the recipe
- In JavaScript: The engine deopts to a general path and keeps the JavaScript result correct
Where the analogy stops: A kitchen can choose any shelf. A JavaScript engine must accept valid JavaScript and compute the right answer.
Speculative optimization
A speculative plan has three parts. First, feedback says what values arrived at one site. Second, optimized code uses that pattern. Third, guards check that the next value still fits. A guard is a runtime test that protects an optimization.
Replay a teaching model of speculative optimization: feedback creates a guarded fast path, and a surprising type bails out safely.
script
observe(feedback, 10, 2);const optimized = compileWithGuards(feedback);optimized(12, 3);optimized("12", "3");In the replay, line 2 warms the add site with numbers. Line 3 compiles a guarded plan. Line 4 passes the guard, but line 5 sends strings. The optimized path bails out, the generic path concatenates, and the result is still "123".
+function totalCents(price, tax) { return price + tax;} console.log(totalCents(100, 8));console.log(totalCents(250, 20));Line 2 always performs numeric addition. The lesson is not “never use strings.” It is “avoid making one hot operation alternate between two meanings unless that is the behavior you really need.”
The feedback lattice
A lattice is an ordered set of states where new evidence can move a site toward a more general state. For the binary add operation, V8 12.4 names hints such as None, SignedSmall, Number, NumberOrOddball, and Any. The exact compiler may split more cases, but the safe teaching model is that a site widens when broader values appear.
| State | What it has seen | What an optimizer may try |
|---|---|---|
| None | No useful evidence yet. | The interpreter collects feedback before optimized code should trust this site. |
| SignedSmall | Small integers, called Smis in V8, have been enough so far. | The optimizer can try tagged-integer math with overflow and Smi checks. |
| Number | At least one non-integer number appeared. | The plan can still be numeric, but it cannot assume integer-only arithmetic. |
| NumberOrOddball | Numbers mixed with undefined, null, or booleans. | The path must include JavaScript conversion behavior. |
| Any | The site mixed incompatible meanings such as number addition and string concatenation. | Use the generic operation unless a later compiler splits control flow more precisely. |
V8's BinaryOperationHint enum in src/objects/type-hints.h includes kNone, kSignedSmall, kNumber, kNumberOrOddball, kString, kBigInt, kBigInt64, and kAny. The test also asks Node 22's V8 to print real feedback for small integers, numbers, and oddballs.
function onlySmi(a, b) { return a + b; }for (let i = 0; i < 30; i += 1) onlySmi(i, i + 1);console.log("ONLY_SMI");%DebugPrint(onlySmi); function smiThenDouble(a, b) { return a + b; }for (let i = 0; i < 30; i += 1) smiThenDouble(i, i + 1);smiThenDouble(1.5, 2.25);console.log("SMI_DOUBLE");%DebugPrint(smiThenDouble); function numberOddball(a, b) { return a + b; }for (let i = 0; i < 30; i += 1) numberOddball(undefined, i);console.log("ODDBALL");%DebugPrint(numberOddball);const slot = createSlot("BinaryOp slot #0");feed(slot, 1, 2); // SignedSmallfeed(slot, 1.5, 2.25); // Numberfeed(slot, undefined, 1); // NumberOrOddballfeed(slot, "7", "8"); // AnyNo calls yet. Start with small integers, then try strings or `undefined`.
Feed values into one + site. The state starts at None, widens as needed, and the teaching model disables optimization after repeated guard failures.
Try the scripted sequence, then reset and feed strings first. A fresh string-only site can specialize differently, but mixing number addition and string concatenation at the same site pushes this model to Any.
Guards, checks, and watchpoints
Inline checks protect facts about values in the current optimized frame. A Smi check protects small-integer math. A map check protects an object layout. A bounds check protects an array access. A watchpoint, also called a code dependency in V8 traces, protects a fact that can be invalidated from elsewhere, such as a stable prototype or constant field.
| Mechanism | Where it lives | Plain-language test | Likely fallback |
|---|---|---|---|
| Smi check | Inline guard in optimized code | if input is not a small integer, deopt now | Eager deopt |
| Map check | Inline guard for an object shape/map | if receiver map changed, deopt now | Eager deopt |
| Bounds or hole check | Inline guard around arrays | if index is out of bounds or the elements kind changed, use the fallback | Usually eager deopt or a slower builtin |
| Stable-map dependency | Watchpoint stored outside this exact instruction | if that map/prototype becomes unstable, mark dependent code stale | Lazy invalidation |
| Constant field dependency | Watchpoint for a property/prototype assumption | if someone writes the field, invalidate compiled code | Lazy invalidation |
A teacher can check one name at the door. If the whole class list changes, later checks need the new list. Both protect a shortcut, but they act at different times.
- In real life: A teacher checks one name now
- In JavaScript: An inline guard checks this value right now
- In real life: A missing name needs an immediate check
- In JavaScript: A guard failure causes eager deopt
- In real life: A changed class list affects many checks
- In JavaScript: A watchpoint invalidates code that depended on a wider fact
- In real life: The next check uses the new list
- In JavaScript: The next safe entry or return takes the lazy fallback
Where the analogy stops: A class list is visible to people. Engine dependencies are internal metadata, and engines implement them differently.
Eager vs lazy deoptimization
Eager deoptimization happens at the failing check. The optimized frame cannot continue because the current value violates a guard. Lazy deoptimization is delayed until a safe point after optimized code was marked invalid by a dependency or watchpoint. Both paths need metadata that maps optimized machine state back to interpreter-level variables.
Replay a teaching model of a dependency invalidation: optimized code is marked stale from the outside, then falls back on the next safe entry.
script
const plan = compilePriceReader(menu);plan({ subtotal: 100 });changeTaxRate(menu, 0.12);plan({ subtotal: 100 });| Kind | Trigger | Trace clue | Examples |
|---|---|---|---|
| Eager deopt | A guard/check in the optimized frame fails right now. | V8's trace line contains bailout (kind: deopt-eager, reason: ...). | Wrong type, wrong map, out-of-bounds, insufficient feedback. |
| Lazy deopt or dependency invalidation | Some outside fact invalidates code that may not be running at the exact site. | V8 can mark dependent code for deoptimization because of code dependencies. | Prototype changes, stable maps, constant fields, protector cells. |
| OSR-related deopt | A loop entered optimized code mid-frame and later exits that optimized path. | The on-stack replacement lesson covers the loop-entry half in detail. | Long loops and early exits from OSR code. |
- A hot
price + taxsite only saw small integers, so the compiler emits a Smi fast path. - The same optimized add site receives
"12"and the Smi guard fails. - A prototype property changes and V8 marks dependent optimized code for deoptimization.
- Convert API prices with
Number(value)once before a hot reducer runs. - A property load compiled for one map receives an object with a different map.
- Use a benchmark with warm-up and representative values before rewriting a helper.
Place each card with the idea it best demonstrates: speculation, eager deopt, lazy invalidation, or a practical fix.
The watchpoint replay is a teaching model. It does not inspect your browser engine. It shows why code can be invalidated by a change outside the exact instruction that later falls back.
Deopt loops
A deopt loop is a pattern where code optimizes for one case, quickly sees another case, deopts, gathers feedback, and then repeats. Engines track this kind of churn and can delay or stop re-optimizing. Your practical fix is usually simpler: keep the hot operation's inputs stable, or split truly different cases into different paths.
+function addOne(value) { return value + 1;} const values = [1, "1", 2, "2"];console.log(values.map(addOne).join("|"));Line 6 calls the same addOne site with numbers and strings. JavaScript prints 2|11|3|21. A real optimizer may learn from that, but if the hot path keeps alternating, repeated specialization attempts can become wasted work.
- Normalize API data once, before it enters a hot loop.
- Keep one hot call site from mixing many unrelated shapes or type meanings.
- Split rare cases out when it improves clarity and measurements say the path matters.
- Prefer readable code; do not contort code for a trace line that is not on a hot path.
Tracing deopts with Node
V8 exposes diagnostic flags in Node. These examples are marked non-runnable in the browser editor because they require Node flags and V8 native syntax. The tests run them in child processes and assert stable facts only: Node starts with v22., V8 starts with 12.4., eager deopt traces include a kind and reason, and dependency invalidation traces mention code dependencies.
function add(a, b) { return a + b;} %PrepareFunctionForOptimization(add);for (let i = 0; i < 100000; i += 1) { add(i, i + 1);}%OptimizeFunctionOnNextCall(add);add(1, 2);add("x", "y");$ node --allow-natives-syntax --no-concurrent-recompilation --trace-deopt probe.js
[bailout (kind: deopt-eager, reason: not a Smi): begin. deoptimizing ...]const proto = { taxRate: 0.05 };const menu = Object.create(proto);menu.subtotal = 100;function price(row) { return row.subtotal * (1 + row.taxRate);}%PrepareFunctionForOptimization(price);for (let i = 0; i < 100000; i += 1) price(menu);%OptimizeFunctionOnNextCall(price);price(menu);proto.taxRate = 0.12;price(menu);$ node --allow-natives-syntax --trace-deopt probe.js
[marking dependent code ... for deoptimization, reason: code dependencies]function add(a, b) { return a + b; }%PrepareFunctionForOptimization(add);add(1, 2);%OptimizeFunctionOnNextCall(add);add(3, 4);const status = %GetOptimizationStatus(add);console.log("status is number", typeof status === "number");Do not assert memory addresses, compile timings, bytecode offsets, exact optimization status numbers, or full trace lines. They vary across platforms and minor builds. Prefer broad evidence such as kind: deopt-eager and the presence of a reason.
Practical habits
Most frontend developers should not chase every deopt. Use this knowledge when a measured hot path matters: a parser, a chart reducer, a data-grid formatter, or an animation loop. Then make the values boring in the best way.
| Habit | What to do | Why it helps |
|---|---|---|
| Normalize at boundaries | Convert API strings to numbers before the hot loop. | A hot add site keeps one meaning instead of alternating between addition and concatenation. |
| Keep object shapes steady | Initialize properties in the same order and avoid delete on hot objects. | Inline caches and map checks stay predictable. |
| Measure before changing code | Use real user paths or representative benchmarks with warm-up. | A deopt trace is a clue, not proof that users are slow. |
| Use diagnostics locally | Run Node or Chrome with trace flags in experiments, not in application code. | Flags and native syntax are V8-specific and can change. |
function toCents(value) { return Number(value);}const inputs = ["100", "50", "25"];console.log(inputs.map(toCents).reduce((sum, value) => sum + value, 0));Line 1 converts each API value once. Line 5 then reduces numbers, not a mixture of strings and numbers. Measure before and after in the application path you care about.
Common misconceptions
- “Deopt means my code is wrong.” No. It means an optimized assumption stopped matching; JavaScript still runs correctly.
- “The feedback lattice narrows after a clean call.” Usually no. Feedback tends to widen so the next plan stays safe.
- “Watchpoints run my JavaScript callback.” No. They are engine dependencies that invalidate compiled code internally.
- “Every trace line must be fixed.” No. Fix measured hot paths, not diagnostic noise.
| Idea | Actually means | Not the same as |
|---|---|---|
| Speculation | An implementation strategy backed by runtime feedback and guarded exits. | A change to JavaScript semantics or a guess that may return the wrong answer. |
| Type feedback | Evidence collected at operation sites. | A guarantee that future values must have the same type. |
| Deoptimization | A correctness-preserving fallback to a less-specialized frame. | An exception or a bug in your program. |
| Trace output | A diagnostic clue for one engine build. | A portable performance contract or something to assert byte-for-byte. |
Practice exercises
Run the code mentally. What is the first printed line?
function totalCents(price, tax) {
return price + tax;
}
console.log(totalCents(100, 8));
console.log(totalCents(250, 20));function totalCents(price, tax) {
return price + tax;
}
console.log(totalCents(100, 8));
console.log(totalCents(250, 20));The first call returns 100 + 8, so it prints 108. The second prints 270.
Use the simplified lattice table. Which state is last?
let state = "None";
function merge(previous, observed) {
if (previous === "None") return observed;
if (previous === observed) return previous;
if (previous === "SignedSmall" && observed === "Number") return "Number";
if (previous === "Number" && observed === "NumberOrOddball") return "NumberOrOddball";
return "Any";
}
state = merge(state, "SignedSmall");
state = merge(state, "Number");
state = merge(state, "NumberOrOddball");
console.log(state);let state = "None";
function merge(previous, observed) {
if (previous === "None") return observed;
if (previous === observed) return previous;
if (previous === "SignedSmall" && observed === "Number") return "Number";
if (previous === "Number" && observed === "NumberOrOddball") return "NumberOrOddball";
return "Any";
}
state = merge(state, "SignedSmall");
state = merge(state, "Number");
state = merge(state, "NumberOrOddball");
console.log(state);The model moves None -> SignedSmall -> Number -> NumberOrOddball, then prints NumberOrOddball.
What does the second call print, and why might that surprise a numeric fast path?
function addFee(value) {
return value + 1;
}
console.log(addFee(4));
console.log(addFee("4"));function addFee(value) {
return value + 1;
}
console.log(addFee(4));
console.log(addFee("4"));The first call prints 5. The second call prints 41, because "4" + 1 concatenates.
Complete the helper so a later reducer sees numbers, not a mix of strings and numbers.
function normalizePrice(value) {
// return a number here
}
const raw = ["100", "50", "25"];function toCents(value) {
return Number(value);
}
const inputs = ["100", "50", "25"];
console.log(inputs.map(toCents).reduce((sum, value) => sum + value, 0));Convert strings to numbers at the boundary so the hot reducer sees stable numeric inputs.
Which substring in the sample output identifies the eager deopt kind?
The substring is kind: deopt-eager. It tells you the optimized frame bailed out immediately at a failing check.
Choose a data-grid, chart, parser, or animation path in an app. What would you inspect before changing code, and what is one safe fix if values alternate?
A good answer names a measured hot path, checks whether values or shapes alternate, then normalizes inputs or splits rare cases only if the measurement improves.
Quiz
Question 1 of 8What is speculative optimization?
Choose an answer to see the explanation.
Question 2 of 8What does this snippet print?
Read the code, then predictfunction addFee(value) { return value + 1; } console.log(addFee(4)); console.log(addFee("4"));Choose an answer to see the explanation.
Question 3 of 8Which BinaryOp feedback path is the lesson's simplified numeric climb?
Choose an answer to see the explanation.
Question 4 of 8What does an inline Smi check usually do when it fails in optimized code?
Choose an answer to see the explanation.
Question 5 of 8What does this lattice model print?
Read the code, then predictlet state = "None"; function merge(previous, observed) { if (previous === "None") return observed; if (previous === observed) return previous; if (previous === "SignedSmall" && observed === "Number") return "Number"; return "Any"; } state = merge(state, "SignedSmall"); state = merge(state, "Number"); state = merge(state, "String"); console.log(state);Choose an answer to see the explanation.
Question 6 of 8What does a watchpoint or code dependency do?
Choose an answer to see the explanation.
Question 7 of 8What is a deopt loop?
Choose an answer to see the explanation.
Question 8 of 8Which production response is best after seeing
--trace-deoptoutput?Choose an answer to see the explanation.
Key takeaways
- Optimized JavaScript is often speculative: fast for the observed case, guarded for correctness.
- Feedback usually widens through a lattice. One surprising value can make a site more general.
- Inline guards tend to cause eager deopts; watchpoints and code dependencies can invalidate code lazily.
- Trace deopts to investigate measured hot paths, then keep types and shapes stable where it improves real work.
One-line summary: speculation makes the common path fast, and deoptimization keeps the uncommon path correct.
Next, What optimizing compilers do shows the classic transformations, such as inlining, constant folding, and bounds-check elimination, that speculation makes possible.