open atlas
↑ Back to track
JavaScript Engine internals JSE · 04 · 05

Deoptimization: falling off the cliff

Deopt abandons optimised machine code and resumes in Ignition at the exact bytecode offset. Eager vs lazy deopt, the frame-translation mechanism that reconstructs the interpreter frame, the catastrophic deopt-loop, and the triggers and flags to diagnose it.

JSE Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A function that ran at 40 ns per call in your benchmark suddenly costs 4 µs per call in production — a hundred times slower than uncompiled would have been. There is no infinite loop, no blocking I/O, no GC storm. What you are watching is a function being optimised and thrown away, optimised and thrown away, dozens of times a second, because one of TurboFan’s guards keeps failing on a value it can’t stop seeing. This is the deopt-loop, and it is the single most expensive way to be wrong about types in JavaScript.

What deopt actually is

A guard failure (lesson 04) does not crash and does not silently produce a wrong answer. It triggers a deoptimization: V8 abandons the optimised machine code for that function and resumes execution in the Ignition interpreter at the exact bytecode offset corresponding to where the optimised code gave up. Execution continues correctly, just slower. The optimised code is discarded (or marked invalid), and the function falls back to its lower tier; if it stays hot and its feedback stabilises, it can be re-optimised later.

There are two flavours, distinguished by what invalidated the assumption.

Eager deopt happens synchronously when a guard fails at runtime — the function is executing optimised code, hits a CheckMaps / CheckSmi / CheckBounds, the check fails, and control bails out right there. The trigger is local: this call saw a value the speculation didn’t allow. In --trace-deopt it appears as DEOPT eager with a reason like wrong map or not a Smi.

Lazy deopt happens when an assumption is invalidated externally, not by the function’s own execution. You redefine a function the optimised code inlined, mutate a prototype it depended on, add a property to a shared object, or change a global. V8 cannot reach into a frame that may be running, so instead it marks all dependent optimised code for deopt on next entry — the code is “lazily” deoptimised the next time it would be called. In --trace-deopt it appears as DEOPT lazy. The classic cause is monkey-patching: Array.prototype.map = ... after hot code has been optimised against the original.

Frame translation: how it returns cleanly

When the guard fails, V8 must resume in the interpreter — but the two frames speak entirely different languages. The hard part of deopt is that the optimised frame and the interpreter frame are completely different layouts. Optimised code keeps values in machine registers and a packed stack frame, often in unboxed representations (a raw int32, a raw float64). The interpreter expects values in its own register file (the interpreter’s “registers” are stack slots) in their tagged, boxed form. To resume in the interpreter, V8 must rebuild the interpreter’s frame from the optimised one.

It does this with deopt metadata recorded at compile time: for each possible deopt point, TurboFan stores a description mapping every live optimised-frame location (this machine register holds local i, this stack slot holds total, this is an unboxed double that must be re-boxed) to the interpreter register it belongs in. At deopt time the deoptimizer reads that metadata and performs frame translation — it allocates and fills a fresh interpreter frame with every live value put back where Ignition expects it, re-boxing unboxed values, then jumps into the interpreter at the saved bytecode offset. A single deopt’s translation is cheap, on the order of microseconds.

The deopt-loop: the actual catastrophe

One deopt is fine — microseconds of bookkeeping, then the function re-warms with feedback that now includes the surprising case, re-optimises with a broader assumption, and stays optimised. The catastrophe is the deopt-loop: a function is repeatedly fed a shape or type it cannot keep stable, so it optimises, deopts, re-optimises (the function is still hot, so V8 promotes it again), deopts again — forever. Each cycle pays the deopt translation plus a fresh TurboFan compile (tens to hundreds of ms) plus the time spent running in the slow tier in between. A function in a deopt-loop is orders of magnitude slower than if you had simply disabled optimisation with --no-opt.

When you see a function oscillating between optimised and deoptimised state in —trace-deopt, look for one of these recurring patterns — each is a way to make a guard fail on a recurring value:

  • Smi overflow — an arithmetic site optimised on SignedSmall produces a value past the Smi range (greater than 2^30-ish), failing CheckSmi every time the big value recurs.
  • Shape change / hidden-class divergence — a call site sees objects with diverging maps; CheckMaps fails. Adding properties in different orders, conditional fields, delete.
  • Changing a field’s type — a field optimised as Smi later holds a string or object, so loads guarded on the field’s representation deopt.
  • Reading arguments in optimised functions — historically forced deopt or blocked optimisation; modern V8 handles many cases but rest parameters are still the safe choice.
  • Out-of-bounds or holey accessCheckBounds failing, or transitioning a packed array to holey elements kind.

You diagnose it with --trace-deopt (every deopt with its function, bytecode offset, and reason) and, in d8 under --allow-natives-syntax, %GetOptimizationStatus(fn) to read the function’s tier bits. The browser-track lesson TurboFan’s speculative engine and the deopt-loop trap walks a full trace-and-fix; here the emphasis is the mechanism and the fix pattern: route the pathological value to a separate slow path before the hot block so the fast path stays type-stable.

Deopt costs and triggers
Single deopt translation
~microseconds
Re-optimise (TurboFan)
tens-hundreds of ms
Deopt-loop slowdown
orders of magnitude
Eager deopt trigger
guard fails at runtime
Lazy deopt trigger
external invalidation
Smi range
~31-bit signed
Diagnose flag
--trace-deopt
Status intrinsic
%GetOptimizationStatus(fn)
Quiz

You monkey-patch `Array.prototype.includes` at runtime, after a hot function that used it has been TurboFan-optimised. What kind of deopt does this cause?

Quiz

Why is a deopt-loop slower than running the same function with `--no-opt` (optimisation disabled entirely)?

Order the steps

Order what happens during a single eager deopt.

  1. 1 Optimised code executes and reaches a guard (e.g. CheckSmi)
  2. 2 The guard fails: the value violates the speculated type
  3. 3 The deoptimizer reads the compile-time deopt metadata
  4. 4 It rebuilds the interpreter frame, re-boxing live values into interpreter registers
  5. 5 Execution resumes in Ignition at the saved bytecode offset
Common mistake

A subtle deopt-loop hides in “defensive” code that returns mixed types: a parser that returns a number for valid input but null for invalid, feeding a hot arithmetic site. For 99% of inputs the site is Smi-stable and optimises; the occasional null fails CheckSmi and deopts; the site re-optimises on Smi again because nulls are rare; the next null deopts again. The fix is not “handle null faster” — it is to split the stream: validate and discard nulls before the hot loop so the arithmetic site only ever sees numbers.

Recall before you leave
  1. 01
    Distinguish eager and lazy deopt with a trigger for each.
  2. 02
    Explain frame translation and why it is needed.
  3. 03
    What is a deopt-loop, why is it catastrophic, and how do you fix it?
Recap

Deoptimization is the safety valve that keeps speculative optimisation correct. When an assumption breaks, V8 abandons the optimised machine code and resumes in the Ignition interpreter at the exact bytecode offset where the optimised code gave up — execution stays correct, just slower. Eager deopt is synchronous: the function’s own optimised code hits a failing guard (CheckMaps ‘wrong map’, CheckSmi ‘not a Smi’, CheckBounds ‘out of bounds’). Lazy deopt is external: redefining an inlined function, mutating a prototype, or changing a global invalidates dependent optimised code, which V8 marks for deopt on next entry because it cannot patch a running frame. The clean return is possible thanks to frame translation: TurboFan records deopt metadata mapping every live optimised-frame value (in registers, often unboxed) to the interpreter register it belongs in, and at deopt time the deoptimizer rebuilds a tagged interpreter frame and jumps to the saved offset. A single deopt costs microseconds and is the system working as designed. The catastrophe is the deopt-loop: a recurring value breaks a guard on essentially every call, so V8 optimises and deopts forever, paying a fresh compile each cycle and ending up orders of magnitude slower than —no-opt. Triggers include Smi overflow, shape/hidden-class divergence, changing a field’s type, reading arguments, and holey/out-of-bounds access. Diagnose with —trace-deopt and %GetOptimizationStatus; fix by routing the pathological value to a slow path before the hot block so the fast path stays type-stable. Now when you see a 100× production regression with no obvious cause, open —trace-deopt before reaching for a profiler — a deopt-loop will announce itself immediately.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.