Why V8 Throws Away Perfectly Good Machine Code: TurboFan's Speculative Bets
TurboFan makes JavaScript fast by betting on types it has only observed, not proven. Here's how speculative optimization and de-optimization work, with a runnable trace example.
16 Dec 2025, 22:22 UTC

Your JavaScript function runs fast for ten thousand calls, then suddenly slows down — not because the data changed dramatically, but because V8 just discarded the optimized machine code it built for that function and started over. This isn't a bug. It's TurboFan working exactly as designed, and understanding the bet it makes explains a whole class of confusing performance behavior in Node.js and Chrome.
The bet: speculative optimization
V8 runs code in tiers. The Ignition interpreter executes everything first, collecting feedback about what actually flows through each operation — which object shapes show up, whether values are small integers or doubles, which branch gets taken. Once a function is "hot," TurboFan compiles it to machine code using that feedback as a set of assumptions.
The key word is assumptions. TurboFan doesn't prove your function only receives integers; it observed integers a few thousand times and generates code that only works for integers, with a guard at the top: "if this argument isn't a small integer, bail out." Bailing out is de-optimization — the engine abandons the optimized code mid-execution and reconstructs the interpreter state to continue correctly, just slower.
This design is why JavaScript can be fast at all. A fully general, type-checked-everywhere compilation would look like the interpreter with extra steps. Speculation lets TurboFan emit code that looks closer to what a C compiler would produce for typed code.
Hidden classes are the other half of the bet
Object property access gets the same treatment. V8 assigns every object a hidden class (also called a Map) describing its layout: which properties exist and at what offsets. If TurboFan sees that point.x is always loaded from an object with the same Map, it compiles the access to a fixed-offset memory load — no dictionary lookup, no string comparison.
Two habits break this:
- Adding properties after construction (
obj.x = 1later, or conditionally) creates Map transitions, so the same function sees multiple shapes. - Passing differently-shaped objects to one call site makes it polymorphic. TurboFan handles a few shapes with inline caches, but megamorphic sites fall back to generic lookups and often stop being worth optimizing.
A worked example you can run
Run this with Node.js (any recent LTS; flags are stable but output format varies by version):
function add(a, b) {
return a + b;
}
// Warm it up monomorphically: small integers only
for (let i = 0; i < 100000; i++) add(i, i + 1);
// Violate the assumption
add("1", 2);Run it from your shell with:
node --trace-deopt --trace-opt demo.jsNo special permissions are needed; these are standard V8 flags. You should see a line marking add for optimization, followed by a deopt entry with a reason such as not a Smi or a wrong-map type check after the string call. The exact wording differs across V8 versions, so treat the reason text as approximate. The point to verify: optimization happened, then a deopt fired precisely when the assumption broke.
To see the performance consequence rather than just the trace, time a monomorphic loop against one that alternates between integer-shaped and string arguments. The polymorphic version typically runs noticeably slower and may trigger repeated deopt/reopt cycles — the "performance cliff" where the engine spends its budget compiling and bailing instead of executing.
The trade-off: deopts aren't free, and neither is the code
De-optimization is correct-by-construction — you never get a wrong answer, just a slower one. But it has real costs:
- Bailout overhead: reconstructing interpreter frames from optimized state is expensive, and TurboFan may recompile afterward, burning CPU on code that might get thrown away again.
- Memory: speculative compilation can produce multiple versions of machine code for one function as assumptions shift, plus the feedback vectors that record type history.
- Unpredictability: a function that's fast in benchmarks can deopt in production the first time it meets a real-world edge case — exactly when load is highest.
The practical response isn't to fear the optimizer; it's to keep hot paths boring. Initialize all properties in constructors in a consistent order. Keep hot functions monomorphic — if you need to handle strings and numbers, split them into separate functions rather than branching inside one. Avoid delete on objects in hot paths, since it can push objects into slow dictionary mode.
How to check your own code
Before refactoring anything, measure. Run your workload (or a representative benchmark) with node --trace-deopt and look for repeated deopts on the same function — one-off deopts during warmup are normal and harmless. If a hot function deopts in a loop, the reason field tells you which assumption broke, and that's usually a five-minute fix: consistent object shapes or a split call site. Re-run the trace afterward to confirm the deopt is gone and the function stays optimized. V8's optimizer is a gambler, but it's a transparent one — it will show you exactly which bets it's losing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.