Speculation & deoptimization
Learn how JavaScript engines optimize for observed types, guard those assumptions, and deopt safely when runtime values change.
- 01Explain speculative optimizationDescribe how the engine makes fast code for the values it has seen, and keeps a way back for values that don't fit.
- 02Tell the two kinds of deopt apartTell a guard that fails right away (an eager deopt) from a watchpoint that marks fast code out of date (a lazy deopt).
- 03Investigate without overdoing itRead a
--trace-deoptline, spot a deopt loop, and pick simple habits that help real code you have measured.
Make the usual case fast, keep a way back
The engine is the program that runs your JavaScript. Chrome and Node use one called V8. When a function runs many times, the engine calls it hot. It then tries to turn the hot function into fast machine code: instructions the computer's processor runs directly.
There is a problem. In JavaScript, the same variable can hold a number now and a string later. Fast machine code works best when it knows the kind of value in advance. So the engine makes a bet.
- It looks at the values a function has seen so far.
- It makes fast code for those values only.
- It adds a quick check, in case a different value shows up later.
This bet is called speculation. If the bet turns out wrong, the engine throws the fast code away and goes back to its slower, general code. That step back is called deoptimization, or deopt for short.
Speculative optimization means the engine makes fast code for the values it has seen, and checks each time that the values still fit. When a check fails, the engine deoptimizes: it leaves the fast code and finishes the work in its slower, general code. Your program still gets the correct answer. Only the speed changes.
Two earlier lessons explain how the engine collects its notes about values: Type feedback and Inline caches. The lesson before this one, Optimizing compilers, explains the part that makes the fast code. This lesson is about the checks that keep the fast code safe, and what happens when one fails.
You keep your favorite snack on the top shelf, because you pick it most days. One day a guest asks for something else. You don't hand them your usual snack. You go to the kitchen and make what they asked for. It takes longer, but they get the right thing.
- In real life: You often pick the same snack
- In JavaScript: The engine's notes show the same kind of value each time
- In real life: You keep it on the top shelf
- In JavaScript: The engine makes fast code for that kind of value
- In real life: A guest asks for something else
- In JavaScript: A check fails, because a new kind of value arrived
- In real life: You go to the kitchen and make it
- In JavaScript: The engine deopts to its general code, and the answer is still right
Speculative optimization: a fast path with a check
A speculative plan has three parts:
- Feedback: notes about which values reached one operation, like one
+. The place where the engine keeps the notes for one operation is called a feedback slot. - Fast code made for those values. This is the fast path.
- A guard: a quick check at the start of the fast code. It asks, "Do these new values still fit?" If not, the engine takes the slow path: its general code, which works for any value.
V8 has a short name for small whole numbers, such as 10 or -3: Smis, short for "small integers". Fast code for Smis can add two numbers in one quick processor step.
Replay a small model of speculation: the notes lead to fast code with a guard, and strings make it deopt safely.
script
observe(feedback, 10, 2);const optimized = compileWithGuards(feedback);optimized(12, 3);optimized("12", "3");This replay is a small model written in JavaScript. It does not look inside your browser's engine. Press Step to go line by line:
- Line 1 makes an empty feedback slot for the
total + feeoperation. - Line 2 adds 10 and 2. The notes now say
SignedSmall: V8's name for "only Smis so far". - Line 3 makes fast code from those notes, with a guard in front.
- Line 4 calls the fast code with 12 and 3. The guard passes, and the result is 15.
- Line 5 calls it with the strings
"12"and"3". The guard fails. The engine leaves the fast code, and the slow path joins the two strings into"123".
The answer "123" is exactly what JavaScript always gives for "12" + "3". A deopt changes which code the engine runs. It never changes what your code means.
+ always addsfunction totalCents(price, tax) { return price + tax;} console.log(totalCents(100, 8));console.log(totalCents(250, 20));Line 2 has the + we care about. Lines 5 and 6 call totalCents with two numbers each time, so this + always adds numbers. The code prints 108, then 270.
This does not mean "never use strings". It means: try not to make one busy + switch between adding numbers and joining strings, unless your program really needs both.
The feedback lattice: notes only get wider
The engine sorts its notes for one + into a few states. The states form a ladder, from narrow to wide. A narrow state fits only a few kinds of values. A wide state fits more. This ladder is called a lattice. When a new kind of value arrives, the state moves up. It does not move back down.
V8 calls the notes for an operation with two sides, like a + b, BinaryOp feedback. In the V8 version inside Node 22 (V8 12.4), its states for + include None, SignedSmall, Number, NumberOrOddball, and Any. V8 calls undefined, null, true, and false oddballs. The real compiler splits some cases further, but this ladder is a safe way to picture it.
Read each row of the table from left to right: the state's name, the values the + has seen, and the fast code the engine can make.
| Feedback state | What has the + seen? | What fast code can the engine make? |
|---|---|---|
None | Nothing yet. The + has not run. | None yet. The engine first collects notes while running its slower code. |
SignedSmall | Only small integers, which V8 calls Smis, like 1 + 2. | Fast code that adds two Smis and checks that the sum still fits. |
Number | At least one number with a decimal part, like 1.5. | Fast code that adds any two numbers, not only whole ones. |
NumberOrOddball | Numbers mixed with undefined, null, true, or false. | Fast code that first turns those odd values into numbers, the way JavaScript does. |
Any | Values that need different jobs, such as adding numbers and joining strings. | General code that works for any value. A later compiler may still split some cases apart. |
+ one step up the ladder. It never steps back down.V8 keeps this list of states in an enum (a fixed list of names) called BinaryOperationHint, in the file src/objects/type-hints.h. It holds kNone, kSignedSmall, kNumber, kNumberOrOddball, kString, kBigInt, kBigInt64, and kAny. This lesson's test also runs Node 22 and asks its V8 to print the real notes for small integers, numbers, and oddballs.
The next code runs only in Node, not in the browser editor. It needs the flag --allow-natives-syntax. That flag turns on V8's own test functions, whose names start with %. %DebugPrint prints what V8 knows about a function, including its feedback.
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);- Lines 1 to 4:
onlySmiadds small integers 30 times. V8 printsBinaryOp:SignedSmall. - Lines 6 to 10:
smiThenDoublealso gets1.5and2.25on line 8. V8 printsBinaryOp:Number. - Lines 12 to 15:
numberOddballaddsundefinedto a number. V8 printsBinaryOp:NumberOrOddball.
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 +. The state starts at None and moves up as new kinds of values arrive. After 3 deopts, the model stops making fast code.
Press "Run the next value" to make seven calls in a set order, or press one call button at a time. Then press Reset and try strings first. A + that has only ever joined strings gets its own state, String. But once the same + has seen both numbers and strings, this model moves it to Any.
Guards, checks, and watchpoints
Fast code can rely on two kinds of facts. Some facts are about the values in this call. Others are about things outside the function.
Checks inside the fast code test the values of this call, right where they are used. Three common ones:
- A Smi check asks, "Is this value still a small integer?"
- A map check asks, "Does this object still have the same layout?" V8 gives every object a hidden map: a record of which properties it has, in which order.
- A bounds check asks, "Is this index still inside the array?"
A watchpoint works differently. It is a note the engine keeps about a fact outside the function, such as "this prototype won't change". (A prototype is the object that other objects borrow shared properties from.) The fast code does not check the fact on every call. Instead, if some other code changes that fact, the engine marks every piece of fast code that relied on it as out of date. "Watchpoint" is a word several engines use. V8's own messages call these code dependencies. The exact details differ from engine to engine.
Read each row of the table as: "this kind of check sits here, asks this, and this happens if it fails."
| Kind of check | Where is it? | What does it ask? | What happens if it fails? |
|---|---|---|---|
| Smi check | Inside the fast code. | Is this value still a small integer? | An eager deopt, right away. |
| Map check | Inside the fast code, before it reads a property. | Does this object still have the same layout (map)? | An eager deopt, right away. |
| Bounds check | Inside the fast code, around an array read. | Is the index inside the array, and does the array still hold the same kind of items? | Usually an eager deopt, or a slower built-in way. |
| Watchpoint on a layout | In a note kept outside the function. | Will objects with this layout stay the same? | The fast code is marked out of date. The engine picks when it is left. |
| Watchpoint on a property | In a note about a property or a prototype. | Will this property keep its value? | The fast code is marked out of date. The engine picks when it is left. |
A teacher checks each child's name at the classroom door. That check happens right there, for one child. Sometimes the school sends a whole new class list. Nobody is at the door at that moment. The teacher just knows the old list is out of date, and uses the new one at the next check.
- In real life: The teacher checks one child's name at the door
- In JavaScript: A guard checks this call's value, right now
- In real life: A name is not on the list, so the teacher stops that child at once
- In JavaScript: A guard fails, so the engine leaves the fast code at once
- In real life: The school sends a new class list
- In JavaScript: Other code changes a fact that the fast code relied on
- In real life: The teacher puts the old list away and uses the new one next time
- In JavaScript: The engine marks the fast code out of date and stops using it later
Eager and lazy deopts
A deopt can happen at two different times:
- An eager deopt happens right at the failing guard. The fast code is in the middle of running, and the value it has is the wrong kind. It cannot go on, so the engine leaves at once.
- A lazy deopt happens later. First, other code changes a fact the fast code relied on, and the engine marks the fast code out of date. The code is left afterwards, at a safe point: a moment the engine picks where it can switch safely.
Leaving fast code in the middle of a call takes some work. Each running call has a frame: a record of its variables and the line it has reached. The engine keeps extra notes about where each variable sits inside the fast code. It uses those notes to rebuild the frame for its slower code, with every variable in place.
Replay a small model of a watchpoint: a change marks the fast code out of date, and a later call takes the slow path. Real engines pick the exact moment.
script
const plan = compilePriceReader(menu);plan({ subtotal: 100 });changeTaxRate(menu, 0.12);plan({ subtotal: 100 });This replay is a small model too. Press Step to go line by line:
- Line 1 makes a
menuwith a tax rate of 0.05. - Line 2 makes fast code that relies on that tax rate. A watchpoint now protects the fact.
- Line 3 runs the fast code. It prints 105, which is 100 × 1.05.
- Line 4 changes the tax rate to 0.12. It does not call the fast code. It only marks it out of date.
- Line 5 calls again. The model sees the mark and uses the slow path. It prints
112.00000000000001: about 112, with a tiny rounding error from how computers store decimals.
Real engines choose their own safe point. A V8 message about a code dependency proves the fast code was marked. It does not tell you the exact moment the code was left.
Read each row of the table as: "this kind of deopt starts like this, shows this in the trace, and often comes from these causes." A trace is a log the engine can print about what it is doing.
| Kind of deopt | What starts it? | What does the trace say? | Common causes |
|---|---|---|---|
| Eager deopt | A guard in the fast code fails right now. | V8's trace line contains bailout (kind: deopt-eager, reason: ...). | The wrong kind of value, a different object layout, an index outside the array, or too few notes. |
| Marked out of date, then a lazy deopt | A change elsewhere marks the fast code out of date. It may be left later, at a safe point the engine picks. | V8 prints marking dependent code ... for deoptimization with reason: code dependencies. That line alone doesn't say when the code was left. | A changed prototype, a changed object layout, or a changed property the code relied on. |
| Deopt from a long loop | A long loop switched to fast code while it was running, and later leaves that fast code. | The On-stack replacement lesson shows how a running loop switches to fast code. | Long loops, and loops that end early. |
- A busy
price + taxhas only seen small integers, so the engine makes fast code for Smis. - The same fast
+gets"12", and the Smi guard fails. - Other code changes a property on a prototype, and V8 marks the fast code that relied on it.
- Convert prices from a server with
Number(value)once, before the hot code adds them up. - A property read made for one object layout gets an object with a different layout.
- Time a helper with a warm-up and realistic values before rewriting it.
Put each card in its group: the engine is making a bet, a guard fails right now, a change elsewhere marks fast code out of date, or a fix you can make in your code.
Deopt loops
A deopt loop is when the same code goes round and round. The engine makes fast code for one kind of value. Soon another kind arrives, and the engine deopts. It collects new notes, makes fast code again, and the same thing happens. All that work is wasted. Engines count these rounds, and after a few they may wait longer or stop making fast code for that function.
function addOne(value) { return value + 1;} const values = [1, "1", 2, "2"];console.log(values.map(addOne).join("|"));Line 2 has the +. Line 5 makes a list that switches between numbers and strings. Line 6 sends every item through addOne, then joins the results with |. It prints 2|11|3|21: 1 + 1 is 2, but "1" + 1 joins into "11".
+ keeps getting numbers and strings in turn, the engine can go round this circle again and again. After a few rounds, it may stop making fast code for that function.Four calls are far too few for the engine to make fast code. But if a hot + kept switching like this thousands of times, the circle in the picture could happen. Your fix is usually simple: keep the values going into a hot operation the same kind, or split different kinds into different functions.
- Convert data from a server into the right kind once, before it reaches a hot loop.
- Don't send many different kinds of values or object layouts through one hot operation.
- Move rare cases into their own function when that makes the code clearer and your timings show it matters.
- Keep code easy to read. Don't twist code to silence a trace line on code that is not hot.
Seeing deopts in Node
Node can print V8's deopts if you start it with special flags (options typed after node). The examples below run only in Node, so they can't run in the browser editor. The flags they use:
--trace-deoptprints a line each time V8 deopts.--trace-optprints a line each time V8 makes fast code.--allow-natives-syntaxturns on V8's test functions that start with%.--no-concurrent-recompilationmakes V8 build fast code right away, so the output comes in a steady order.
The lesson's test runs these examples in Node and checks only facts that stay the same between runs: Node's version starts with v22., V8's starts with 12.4., an eager deopt line has a kind and a reason, and an out-of-date line mentions 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");- Line 5:
%PrepareFunctionForOptimizationtells V8 to start keeping notes foradd. - Lines 6 to 8 call
add100,000 times with small integers. - Line 9:
%OptimizeFunctionOnNextCalltells V8 to make fast code on the next call. - Line 10 runs the fast code with 1 and 2. The guard passes.
- Line 11 sends the strings
"x"and"y". The Smi guard fails, and V8 deopts.
$ node --allow-natives-syntax --no-concurrent-recompilation --trace-deopt probe.js
[bailout (kind: deopt-eager, reason: not a Smi): begin. deoptimizing ...]bailout means "left the fast code". kind: deopt-eager says it happened right at a guard. reason: not a Smi says which check failed. The ... stands for details that change from run to run.
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);- Lines 1 to 3 make
protowith a tax rate, and amenuthat borrows from it.Object.create(proto)makes a new object whose prototype isproto. - Lines 4 to 6:
pricereadsrow.taxRate, which it finds on the prototype. - Lines 7 to 10 warm up
price, make fast code, and run it once. V8 assumes the prototype's tax rate won't change. - Line 11 changes
proto.taxRate. V8 marks the fastpricecode as out of date. - Line 12 calls
priceagain, after the mark.
$ node --allow-natives-syntax --trace-deopt probe.js
[marking dependent code ... for deoptimization, reason: code dependencies]The clue is marking dependent code ... for deoptimization with reason: code dependencies: fast code that depended on the prototype was marked.
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");Lines 2 to 5 prepare add, call it, ask for fast code, and call it again. Line 6 asks V8 for a status number with %GetOptimizationStatus. Line 7 only checks that it is a number and prints status is number true. The exact number changes between V8 versions, so the test doesn't rely on it.
Don't write tests that expect memory addresses, compile times, positions in the compiled code, exact status numbers, or whole trace lines. They change between computers and even small V8 updates. Check broad clues instead, such as kind: deopt-eager and that a reason is there.
Habits for real code
Most frontend developers don't need to chase every deopt. This knowledge helps when you have measured a slow, hot path: a parser, the code that adds up chart data, a function that formats every cell of a big table, or an animation loop. In that code, keep the values plain and predictable.
Read each row of the table as: "this habit means doing this, and it helps because of this."
| Habit | What to do | Why it helps |
|---|---|---|
| Convert data early | Turn text from a server into numbers before the hot loop. | The hot + keeps one job, adding, instead of switching between adding and joining text. |
| Keep object layouts steady | Create properties in the same order, and don't delete properties from hot objects. | Map checks keep passing, so property reads stay fast. |
| Measure before changing code | Time real user actions, or a test that warms up first and uses realistic values. | A deopt line is a clue. It doesn't prove that users feel any slowness. |
| Use trace flags only in experiments | Run Node or Chrome with trace flags on your own computer, never in your app's code. | The flags and % functions belong to V8 and can change in any version. |
function toCents(value) { return Number(value);}const inputs = ["100", "50", "25"];console.log(inputs.map(toCents).reduce((sum, value) => sum + value, 0));Lines 1 to 3 make toCents, which turns a value into a number with Number(value). Line 4 is data from a server, where the prices arrive as text. Line 5 first turns every price into a number with toCents, then adds them up with reduce. So the + inside reduce only ever adds numbers. It prints 175. If speed matters, time the real page before and after the change.
Common mix-ups
- "A deopt means my code is wrong." No. It means the engine's bet stopped fitting. Your code still gives the right answer.
- "The notes go back to narrow after a tidy call." Usually not. Notes get wider, so the next fast code stays safe for every value seen.
- "A watchpoint runs my JavaScript function." No. It is a note inside the engine. It marks fast code out of date and never runs your code.
- "Every trace line must be fixed." No. Fix hot paths you have measured. Ignore lines from code that rarely runs.
The table below puts four terms side by side. Read each row as: "this term means this, and it does not mean that."
| Term | What it means | What it doesn't mean |
|---|---|---|
| Speculation | The engine's bet: fast code for the values it has seen, with guards and a way back. | A change to what JavaScript means, or a guess that could give a wrong answer. |
| Type feedback | Notes about the kinds of values that reached each operation. | A promise that future values will be the same kind. |
| Deoptimization | A safe step back to slower code that still gives the right answer. | An error or a bug in your program. |
| Trace output | A clue from one version of one engine. | A promise that stays the same everywhere, or text a test should match exactly. |
Practice exercises
The first exercises ask what code prints. The later ones ask you to read a trace and fix real code. Try a guess first, then use the hints if you get stuck.
Read the code without running it. What is the first line it prints?
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 call prints 270.
Use the feedback ladder table. Which state does the code print at the end?
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 state moves from None to SignedSmall, then Number, then NumberOrOddball. So it prints NumberOrOddball.
What does the second call print? Why would that surprise fast code made only for numbers?
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 joins the text "4" and "1".
Finish the function so that code which adds up the prices later gets numbers, not a mix of text and numbers. Then name the fix in a few words.
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));Turn the text into numbers first, so the hot + that adds them up only ever sees numbers.
Look at the sample eager deopt line in "Seeing deopts in Node". Which part of it names the kind of deopt?
The text is kind: deopt-eager. It tells you the fast code was left at once, right at a failing guard.
Pick a big table, a chart, a parser, or an animation in an app. What would you check before changing code? If the values keep switching kinds, what is one safe fix?
A good answer names one measured hot path, checks whether its values or object layouts keep switching, and then converts the values early or moves rare cases out. Keep the change only if the timing gets better.
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 8In this lesson's feedback ladder, which order do the states go in for a
+?Choose an answer to see the explanation.
Question 4 of 8What usually happens when a Smi check fails in fast 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 (V8 calls it a 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 8You see
--trace-deoptoutput. What is the best next step for a real app?Choose an answer to see the explanation.
Key takeaways
- The engine makes fast code for the values it has seen, and puts guards in front to check every new value.
- The notes for an operation only get wider. One new kind of value can move an operation up the ladder for good.
- A failing guard causes an eager deopt, right away. A change elsewhere marks fast code out of date, and it is left later: a lazy deopt.
- Use deopt traces to study hot paths you have measured. Keep the kinds of values and object layouts steady where it really helps.
One-line summary: speculation makes the usual case fast, and deoptimization keeps the unusual case correct.
Next, What optimizing compilers do shows the changes the compiler makes to your code once it trusts its bets, such as copying a small function into its caller (inlining) and working out sums ahead of time (constant folding).