Deoptimization: Falling Off the Fast Path

A function in your hot loop was flying. Millions of calls, tight machine code, the profiler barely noticed it was there. Then you added a line that, every so often, hands it a string where a number used to go. And the whole loop got slow. Not the calls with the string in them. All of them. Even the millions that are still plain integers doing plain arithmetic.

Nothing threw. Nothing looks broken. The code does exactly what it did yesterday, only now it crawls, and reading the source will not tell you why, because the source did not change in any way that should matter.

What happened is that the fast version of that function got thrown out mid-run, and the engine could not build a good replacement because the inputs stopped being predictable. That throwing-out has a name, and a function that keeps doing it is one of the sharpest performance cliffs in the language.

A guard is a bet you can lose

Two lessons back, the optimising tier turned your hot function into specialised machine code by making bets: this argument is always a number, this object always has shape A, this array is always packed integers. A bet that might be wrong has to be checked, so each one compiles down to a cheap guard in front of the fast path. The guard is the whole reason speculation is safe: while it passes, you run the tight code; the moment it fails, the fast code is known to be wrong for this input, so the engine must not run it.

So what does the engine do instead? It cannot just skip ahead. It cannot run the fast code, because the fast code assumes the very thing that just turned out to be false. It has one safe option: throw the specialised code away and go back to the interpreter, which has no assumptions baked in and handles every case correctly. Slower, but always right.

That fallback is deoptimisation. A wrong bet is never a wrong answer. It is only ever a slower one.

The bailout: unwinding a live frame

Here is the part that makes deopt cost more than you might guess. The guard does not fail politely between function calls. It fails in the middle of running optimised code, several statements deep, with values sitting in CPU registers and on the stack in a layout the optimiser chose for speed. The interpreter expects a completely different layout: named local slots, an operand stack, a bytecode offset to resume from. You cannot just jump from one to the other.

So the engine performs a bailout. Using metadata it recorded at every point where a deopt could happen, it reconstructs the interpreter’s stack frame from the optimised one: it works out where each live value ended up, materialises it back into the slot the interpreter would expect, and sets execution to resume at the exact bytecode offset of the operation that failed. Not the top of the function. The engine cannot restart the call from scratch, because side effects earlier in the function already happened, so it has to pick up precisely where the fast code left off.

one guard fails, and the whole live frame has to be unwoundoptimised machine code, running while its guards holdmap is Aholdsarg is a numbera string arrived, failsindex in rangenot reachedthe failing guard short-circuits, so later guards never runguard failsBAILOUTrebuild the interpreter frame from the optimised one,materialise each live value into its interpreter slot,resume at the exact bytecode offset that failedresumediscardIgnition interpreter resumeshandles the string correctly, just sloweroptimised codeunlinked and thrown away
A guard fails inside running optimised code. The engine rebuilds the interpreter frame from the optimised one, resumes at the same bytecode offset, and discards the fast code.

All of that reconstruction is real work, which is why a deopt is not free. But be careful with the conclusion. A single deopt is cheap in the grand scheme, and it is completely normal. Functions warm up, get optimised on early evidence, hit one case the evidence did not cover, deopt once, get re-optimised on the fuller picture, and then run fast forever. That one-time cost is invisible. The problem is never a deopt. The problem is a deopt that keeps happening.

Three ways to trip a guard

Every deopt is some guard discovering that reality diverged from what the feedback promised. There are only a handful of shapes this takes, and they map directly onto the bets the optimiser made. Here are the three most common, side by side.

arithmeticsum(a, b)property readp.xarray elementnums[i]feedback: always sawsmall integersfeedback: always shapeA, that is {x, y}feedback: packedintegers [1, 2, 3]then this arrivessum(2, “3”)then this arrivesa {y, x, z} objectthen this arrivesnums.push(4.5)guard: is b a number?reason: not a numberguard: still map A?reason: wrong mapguard: still packed int?reason: elements changedtype guardshape guardelements guardevery one ends the same way: guard fails, deopt, back to the interpreter
Three different guards, three different assumptions, the same outcome. A wrong type, a wrong shape, or a changed array all fail their guard and fall back to the interpreter.

In code, all three are boring and easy to write by accident:

// 1. type guard: an int-only add suddenly sees a string
function sum(a, b) {
  return a + b;
}
sum(2, 3);      // feedback: numbers
sum(2, "3");    // "23" is correct, but the numeric fast path can't produce it → deopt

// 2. shape guard: a load specialised for one shape meets another
function dist(p) {
  return p.x * p.x + p.y * p.y;
}
dist({ x: 1, y: 2 });          // shape A
dist({ y: 2, x: 1, z: 0 });    // different property order + extra key → different shape → deopt

// 3. elements guard: a packed-integer array grows a float or a hole
const nums = [1, 2, 3];        // packed small integers
nums.push(4.5);                // now it must hold doubles → elements kind changes → deopt
nums[10] = 1;                  // a hole at indices 4..9 → another elements-kind change

The first is a plain type change. The second is a different hidden class, which is why property order and late-added keys matter so much. The third is an elements-kind transition, the array-specific version of the same story: V8 specialises tightly for packed integers, and a stray float or a hole forces it to a more general representation. Different guards, one lesson: the optimiser bet on a stable world, and the world moved.

The real villain: the deopt loop

One deopt is a hiccup. The thing that actually wrecks performance is a deopt loop, and it is worth drawing, because it explains slowness that no single line of code can account for.

Picture a function that genuinely sees a mix of inputs, run after run, in a way that never settles. V8 optimises it on the first batch of feedback. A different case shows up and it deopts. It gathers the new feedback and re-optimises. Then a case from the first batch shows up, contradicts the new assumptions, and it deopts again. Now it is ping-ponging: optimise, deopt, re-optimise, deopt, paying the compile cost every cycle and spending the gaps between cycles running the slow interpreted version anyway.

optimise once, stay fastslowfastoptimise, and it holdswarming updeopt loop, never settlesslowfastoptoptdeoptdeopteach spike is a fresh compile; the code spends most of its life on the slow floor between them
Top: a function that optimises once and stays fast. Bottom: a deopt loop that keeps re-optimising and falling back, so it pays the compile cost repeatedly and rarely runs the fast code.

This is the counter-intuitive part: a function in a deopt loop is slower than one that was never optimised at all. The un-optimised function just plods along in the interpreter at a steady, modest speed. The looping one plods along at the same speed and repeatedly burns compiler time trying to escape, only to get knocked back down. You are paying for the optimiser and getting none of the benefit.

The good news is that engines are not naive about this. V8 notices when a function deopts too many times and eventually stops trying to optimise it, so a true loop tends to give up and settle into plain interpreted execution rather than thrash forever. That is a floor on the damage, not a fix. The function still runs slow. And the exact threshold is an internal detail you should not build on.

How you would even notice

None of this shows up as an error. There is no exception, no warning, no red text. A deopt loop looks like one thing only: a function that the profiler says is hot and expensive, doing work that looks like it should be cheap. That mismatch (simple code, surprising cost) is the tell.

Once a profile points you at a suspect function, you can confirm it by asking the engine to narrate its own decisions. In Node that is a pair of flags:

# Ask V8 to log every optimise and every deopt, then run your workload.
node --trace-opt --trace-deopt app.js

The output is verbose, but you are hunting for one pattern: the same function name being optimised and deoptimised over and over, each deopt carrying a short reason. Schematically, a healthy function optimises once and goes quiet, while a looping one looks like this:

# illustrative, not literal V8 output (real lines are longer and noisier)
[marking priceOf for optimization]
[optimizing priceOf]
[deoptimizing priceOf: reason: not a Smi]
[optimizing priceOf]
[deoptimizing priceOf: reason: wrong map]
[optimizing priceOf]
[deoptimizing priceOf: reason: Insufficient type feedback ...]
# ...priceOf never stays optimized

That is the whole diagnosis. The function name tells you where, the reason tells you what assumption broke, and the repetition tells you it is a loop rather than a one-time warm-up. From there the fix is almost always about making the input stop varying, which is the next section.

See it fall, and recover

Below is a simulation of the whole cycle. A hot loop is “running” and processing iterations. Keep Monomorphic checked and it warms up, tiers up to optimised, and its throughput jumps. Hit Send a string to inject one off-type value and watch it deopt back to the interpreter, then recover on its own. Uncheck Monomorphic to let odd values keep arriving, and you will see the deopt loop: it can never stay on the fast path. This is a canned model of the state transitions, not a real benchmark, but the shape of it is exactly what the flags above would show you.

interactiveA hot loop tiering up, deopting, and looping

Writing code the optimiser can keep

Here is the guidance, and the most important sentence in the lesson: you do not hand-tune for the optimiser. You write code with stable types, consistent object shapes, and arrays that hold one kind of thing, and deopts take care of themselves. Everything below is just that idea made concrete. None of it is a trick. It is what clean, predictable code already looks like.

before: mixed inputsintstrobjintinto the same hot functionthrashes, never settlespays the compiler over and over, runs slow betweenafter: one steady typeintintintintinto the same hot functionoptimised once, stays fastone compile, then the fast path for the rest of the run
The same hot function, two diets. Fed a zoo of types it thrashes between optimised and interpreted; fed one steady type it optimises once and stays there.

The concrete moves are the same ones every lesson in this part keeps landing on:

  • Build objects the same way, every time. Same properties, same order, ideally from one constructor or factory, so they share a hidden class. This is the single biggest source of accidental shape guards.
  • Keep a hot function’s arguments one type. A function called with numbers here and strings there gives its arithmetic nothing to specialise on. If you truly need both, two small functions beat one that keeps deopting.
  • Keep arrays homogeneous. Do not mix integers and floats in a numeric array you care about, and avoid making it sparse by writing past the end and leaving holes. Both force elements-kind transitions.
  • Do not delete properties on hot objects. delete can knock an object out of its fast shape into a slower dictionary mode. Set the property to null, or model “sometimes present” data with a Map.

A tiny before-and-after makes it concrete:

// Before: the same function fed three shapes and a string. It cannot stay optimised.
function priceOf(item) {
  return item.price * item.qty;
}
priceOf({ price: 3, qty: 2 });
priceOf({ qty: 5, price: 4, note: "x" }); // different shape (order + extra key)
priceOf({ price: "9", qty: 1 });          // a string in the multiply

// After: one factory, one shape, numbers only. It optimises once and stays.
function makeItem(price, qty) {
  return { price, qty }; // same keys, same order, always
}
function priceOf(item) {
  return item.price * item.qty;
}
const items = [makeItem(3, 2), makeItem(4, 5), makeItem(9, 1)];
items.forEach(priceOf);

The “after” is not cleverer. It is just consistent, and consistency is the entire game.

The warnings that no longer apply

A lot of folk advice about deopts is fossilised. It describes Crankshaft, the optimising compiler V8 retired years ago, which bailed out of whole categories of syntax it could not handle. The modern pipeline (Ignition, Sparkplug, Maglev, and TurboFan) is far more capable, and most of the old taboos are dead.

What still genuinely costs you has not changed, because it is about shape and type stability, not syntax: mixing types into one operation, minting new hidden classes by building objects inconsistently, changing an array’s elements kind, and delete. Those trip guards because they violate a real assumption. A try block does not violate anything. This is the line to hold against cargo-culting: reach for a mechanism, not a superstition. If you cannot name the guard a construct would break, it probably breaks none.

Same idea in every engine

Deoptimisation is not a V8 quirk. Any engine that speculates has to be able to un-speculate, so every one of them has this exact escape hatch under a different name.

Summary

  • Optimised code is fronted by cheap guards that check the assumptions it was built on. When a guard fails, the fast code cannot be trusted, so the engine discards it and falls back to the interpreter. That fallback is deoptimisation, and its purpose is correctness: a wrong bet is only ever slower, never wrong.
  • The fallback is a bailout: the engine rebuilds the interpreter’s stack frame from the running optimised one and resumes at the exact bytecode offset that failed. That reconstruction is why a deopt is not free, though a single one is cheap and normal.
  • Guards fail for a small set of reasons: a type changed (a string in a numeric op), a shape changed (a new hidden class), or an array’s elements kind changed (a float or a hole in a packed integer array).
  • The real cost is a deopt loop: optimise, deopt, re-optimise, deopt, paying the compile cost repeatedly while mostly running slow. A looping function is worse than one never optimised at all. Engines eventually give up optimising it, which caps the damage but does not fix it.
  • You find deopts with a profiler first (a hot function that looks too cheap to be hot), then confirm with --trace-opt --trace-deopt. You never tune the optimiser by hand.
  • Many old deopt taboos (try/catch, arguments, let/const) are obsolete on the modern pipeline. What still costs you is type and shape instability, not syntax.

What this means for your code: build objects consistently, feed hot functions steady types, keep arrays homogeneous, and skip delete on hot objects. Do that and the guards keep passing, the optimiser keeps its work, and you never think about deopts again. The moment a profiler says a simple hot function is mysteriously expensive, this is the mechanism to suspect.