• Terms of Service

Learn Internet Grow

Tech Skills

  • Learn
  • Books
  • Search
  • Create
  • Measure
  • Secure
  • Share
  • Terms of Service

JavaScript Deoptimization: The Optimisation That Withdraws Without Telling You

Filed Under: Create

An enormous lit rope safety net slung across a dark empty theatre, with a thin unoccupied high wire far above it

Here is a function that ran 600,001 times on a laptop on 9 September 2026, under Node v22.18.0 (V8 12.4.254.21-node.27), and got slower because of one of those calls.

function totalise(order) {  return order.price * order.qty;} function makeOrder(price) {  return { price: price, qty: 3 };} for (let i = 0; i < 300000; i++) sink += totalise(makeOrder(i % 97)); const late = makeOrder(11);late.discount = 0.1;          // one property, added after constructionsink += totalise(late); for (let i = 0; i < 300000; i++) sink += totalise(makeOrder(i % 97));

JavaScript deoptimization is the mechanism that fires on the line after the comment, and the program does not mention it. There is no exception, no warning, no return value, no event, no console entry. The arithmetic stays correct. Run it and you get the right number. The only way to see what happened is to ask V8 directly, with a flag that is switched off by default and labelled, in V8’s own source, developer-only:

$ node --trace-opt --trace-deopt demo.js[marking … <JSFunction totalise …> for optimization to TURBOFAN, … reason: hot and stable][completed compiling … <JSFunction totalise …> (target TURBOFAN) - took 0.010, 0.693, 0.003 ms][bailout (kind: deopt-eager, reason: wrong map): begin. deoptimizing … <JSFunction totalise …>, … <Code TURBOFAN>, opt id 1, bytecode offset 0, …][marking … <JSFunction totalise …> for optimization to TURBOFAN, … reason: hot and stable][completed compiling … <JSFunction totalise …> (target TURBOFAN) - took 0.040, 0.238, 0.008 ms]

(Pointer addresses elided with …; every other character is as emitted. Reproduced on three consecutive runs.)

Read V8’s own two strings against each other. The optimiser promoted totalise because it judged the function hot and stable. Then it threw the compiled result away because of a wrong map — a different hidden class arriving at a call site that had only ever seen one. Nine tenths of a millisecond of TurboFan compile work, discarded and paid for again, because one object out of six hundred thousand grew a property after it was built.

This is the asymmetry that makes speculative engines strange to reason about. A compiler either compiles your code or refuses it. You find out at build time, loudly, with a file and a line number. A speculative JIT does something no ahead-of-time compiler ever does: it quietly withdraws work it has already done, and hands you back a program that is still correct and merely slower. Nothing in the language is obliged to tell you, and nothing does.

The word does not appear in the standard

That is not an oversight in the tooling. It is a consequence of where the mechanism lives.

Search the published ECMAScript 2026 edition — the dated snapshot at tc39.es/ecma262/2026/, 7.5 MB of HTML, script, style and SVG blocks stripped before matching, loaded 9 September 2026 — and the vocabulary of this article is simply absent:

String searched in ES2026Occurrences
deoptimization / deoptimisation0
inline cache0
hidden class0
JIT / just-in-time0
monomorphic / megamorphic0
speculat…2 — both in §29, the memory model
optimiz…10 — inspected individually; none concerns compiling JavaScript

The ten optimiz… hits are about WeakRef liveness, %TypedArray%.prototype.sort, Atomics.isLockFree (“This function is an optimization primitive”) and the sticky-flag note. Not one is about making your code fast. And the only place the standard mentions speculation at all, it is talking about the processor, not the engine — from §29 Memory Model:

These astonishments arise from compiler transforms and the design of CPUs (e.g., out-of-order execution and speculation).

So the specification that defines JavaScript does not contain the word for the thing that decides how fast your JavaScript runs. There is no deopt event to listen for because the language never promised there would be a deopt. Speed is entirely an implementation concern, and an implementation concern has no obligation to report itself. That is the whole explanation for the silence, and it is not going to change.

A worn diagonal footpath across a field blocked by a newly chained gate carrying no notice, with a long metalled road curving the slow way round

What V8 means by it, and what WebKit refuses to call it

V8’s clearest published definition is recent, and it arrives in a post that is nominally about WebAssembly. From Speculative Optimizations for WebAssembly using Deopts and Inlining, 24 June 2025:

If the program later behaves differently and thus violates assumptions made when generating the optimized code, V8 performs a deoptimization (or deopt, for short). That means throwing away the optimized code and continuing execution in unoptimized code (and collecting more feedback to possibly tier-up again later).

Note what that sentence is careful about. The deopt is not the failure — it is what makes the earlier gamble legal. Without a reliable way to abandon optimised code mid-execution, no engine could emit integer arithmetic for a * b on the strength of a hundred thousand integers, because the hundred-thousand-and-first value might be a string. The bailout converts an unsafe assumption into a safe one.

WebKit puts this more forcefully than V8 does, to the point of rejecting the industry’s word for it. From Filip Pizlo’s Speculation in JavaScriptCore, 29 July 2020:

Note that what we call OSR exit in this post and in JavaScriptCore is usually called deoptimization elsewhere. We prefer to use the term OSR exit in our codebase because it emphasizes that the point is to exit an optimized function using an exotic technique (OSR). The term deoptimization makes it seem like we are undoing optimization, which is only true in the narrow sense that a particular execution jumps from optimized code to unoptimized code.

Two shipping engines, two names, one mechanism — worth remembering whenever someone says the JavaScript engine. Pizlo also names the property that matters most here: the exit is invisible to the program’s meaning.

because OSR check failure does not affect semantics (we exit to the same point in the same code, just with fewer optimizations), we can hoist those checks as high as we want

The bailout is a correctness device, and its correctness is exactly why it can be silent. Nothing observable to your program changed, so there is nothing for the engine to report.

A close raking view of a thick rope safety net still trembling from a weight that has just left it, over an unlit drop

The oldest evidence for that framing is that the term was not coined for speed at all. WebKit’s own account, in the same post, traces the mechanism to Hölzle, Chambers and Ungar — “The same three authors later invented OSR exit, though they called it dynamic deoptimization and only used it to enhance debugging.” Their PLDI ’92 paper, Debugging Optimized Code with Dynamic Deoptimization, opens:

Self’s debugging system provides complete source-level debugging (expected behavior) with globally optimized code. It shields the debugger from optimizations performed by the compiler by dynamically deoptimizing code on demand.

Deoptimization only affects the procedure activations that are actively being debugged; all other code runs at full speed.

Deoptimisation was invented so a human could see the truth about optimised code. Its career as the enabling half of a speed feature came afterwards — and that history is the better guide to it. The mechanism is, first and last, a way of putting a program back into a state that can be reasoned about honestly.

The assumptions you can break without noticing

The wrong map bailout at the top of this article came from a hidden class changing — one object gained a property after construction and stopped matching the shape that call site had cached. That is the famous case, and every article covers it. The others are less discussed and easier to trip.

Array element representations transition one way only. V8 tags every array with an elements kind and specialises operations against it. From Elements kinds in V8, 12 September 2017:

What’s important here is that elements kind transitions only go in one direction: from specific (e.g. PACKED_SMI_ELEMENTS) to more general (e.g. PACKED_ELEMENTS). Once an array is marked as PACKED_ELEMENTS, it cannot go back to PACKED_DOUBLE_ELEMENTS, for example.

It’s only possible to transition downwards through the lattice. Once a single floating-point number is added to an array of Smis, it is marked as DOUBLE, even if you later overwrite the float with a Smi. Similarly, once a hole is created in an array, it’s marked as holey forever, even when you fill it later.

“Forever” is the operative word, and there is no undo for the undo. The array does not recover when the offending value leaves. And the trigger can be almost nothing: the same post notes that “just adding -0 to an array of small integers is enough to transition it to PACKED_DOUBLE_ELEMENTS“, and NaN and Infinity do the same.

⚠️ Date that claim, because it has already moved. The lattice paragraph now carries an inline correction from V8: “Update @ 2025-02-28: There is now an exception to this for Array.prototype.fill specifically.” A rule stated flatly in 2017 acquired a carve-out eight years later, and the only notice anyone got was a note appended to a blog post.

Adding and removing enough named properties demotes the object out of the fast world entirely. From Fast properties in V8, 30 August 2017:

However, if many properties get added and deleted from an object, it can generate a lot of time and memory overhead to maintain the descriptor array and HiddenClasses. Hence, V8 also supports so-called slow properties.

“Enough” has a number, and it is a flag rather than a law. V8’s flag definitions describe --fast-properties-soft-limit, default 12, as limiting “the number of properties that can be added to an object using keyed store before transitioning to dictionary mode”. A dictionary-mode object is not deoptimised in the bailout sense; it has simply left the game. It will never be the subject of a shape guess again.

And a single out-of-bounds read can retire a load permanently. The V8 post is unusually blunt about this one:

Once a load has run into this situation, V8 remembers that “this load needs to deal with special cases”, and it will never be as fast again as it was before reading out-of-bounds.

Reading array[42] when array.length === 5 is not an error — it evaluates to undefined, which is exactly the problem. The behaviour is specified, the program keeps running, and one load site is quietly downgraded for the rest of the process.

A tall stack of identical crates thrown out of true by one lid standing slightly proud, beside a second stack that is perfectly plumb

The number everyone quotes, read off the machine instead of the internet

Which brings us to the most-repeated claim in this subject: that four distinct shapes at one call site tip its inline cache into the megamorphic state, where per-site caching is abandoned.

⚠️ That figure is engineer commentary from 2015, not a current official V8 number, and it should never be quoted as a constant. Its source is Vyacheslav Egorov — a former V8 engineer, writing on a personal blog, in a post dated 11 January 2015 — whose wording is “maximum possible capacity for the inline cache (e.g. 4 for property loads in V8)”. Excellent on the mechanism. Eleven years old as a number, about a value V8 has never promised to hold still, and there is a second, unrelated four in circulation: V8’s 2025 WebAssembly post describes call-target feedback going “megamorphic (more than four targets, where we don’t inline any more…)”, which is a different subsystem in a different language. Merging the two produces a fact that neither source states.

The honest move is not to pick a number. It is to notice that this is a flag with a default, and to read it out of the binary in front of you:

$ node --v8-options | grep -A 1 max-valid-polymorphic-map-count  --max-valid-polymorphic-map-count (maximum number of valid maps to track in POLYMORPHIC state)        type: int  default: --max-valid-polymorphic-map-count=4

That is 4, on Node v22.18.0 shipping V8 12.4.254.21-node.27, checked 9 September 2026. So the folk number is not wrong — it matches the engine most people are running.

Now read the same setting out of V8’s current tree. In src/flags/flag-definitions.h on main, fetched the same day:

#define DEFAULT_MAX_POLYMORPHIC_MAP_COUNT 10DEFINE_INT(max_valid_polymorphic_map_count, DEFAULT_MAX_POLYMORPHIC_MAP_COUNT,           "maximum number of valid maps to track in POLYMORPHIC state")

Ten. The default has been raised in V8’s development tree while a shipping LTS Node still reports four, and no blog post announced it. ⚠️ Two caveats, because a header is authoritative only about itself: this is what main defined on 9 September 2026, not what any particular Chrome or Node build does, and V8’s own comment says maps tracked in the POLYMORPHIC state, which is narrower than “the megamorphic threshold” in general.

That is the point worth carrying away. The taxonomy is stable; the numbers are settings. The same file shows the taxonomy itself moving — a --homomorphic-ic flag, off by default and implied by --future, adds a state for “same-handler highly polymorphic ICs”, and an experimental --mega-dom-ic adds another for API objects. Monomorphic/polymorphic/megamorphic is a good model and an incomplete list.

Two identical brass gauges with blank faces on one machine housing, their needles resting at different angles, beside a curling unlit manual

The bug is not the deopt. The bug is the repeat.

One deopt costs you a compile you had already paid for. That is a bounded loss and usually a rounding error.

The pathological case is the engine re-optimising on the assumption that just failed, failing again, and cycling. Now you pay the compile repeatedly and keep the result never. A function stuck in that loop is strictly worse off than a function the optimiser had ignored from the beginning, because the interpreted version at least runs uninterrupted, while the looping version pays for optimised code it never gets to use.

This is not a thought experiment. It is one of the reasons V8 replaced an entire optimising compiler. Land ahoy: leaving the Sea of Nodes, 25 March 2025, listing what went wrong with Crankshaft, records that “It suffered from many performance cliffs and bailouts” and that a single unlucky edge case “could cause performance to drop by a factor 100”, before naming the loop itself: “It contained many deoptimization loops.” Not a tuned heuristic in response — a replaced compiler.

The most explicit published defence against it belongs to JavaScriptCore, which treats “how often has this function betrayed us” as a first-class input to its tier-up control system:

JavaScriptCore counts executions of functions and loops to decide when to compile. Once a function is compiled, we count exits to decide when to throw away compiled functions. Finally, we count recompilations to decide how much to back off from recompiling a function in the future.

The back-off is exponential and the thresholds are published:

Then we introduce exponential backoff based on the number of times that the function has been recompiled. The pow(2, R) expression means that each recompilation doubles the thresholds.

For a normal exit, we require 100 pow(2, R) exits to recompile.*

A function that has been recompiled three times needs eight times as much evidence before anyone will try again. And a function whose exits keep landing it back in a hot loop is treated as a worse offender: JavaScriptCore allows only 5 * pow(2, R) of those before it acts. The engine is not trying to avoid deopts. It is trying to avoid believing itself twice about the same thing. Pizlo states the strategy plainly — “the best way of dealing with a wrong speculation is to throw away the optimized code and try optimizing again later with better profiling.”

That is the shape of the whole subject in one sentence: retreat is cheap, and repeating the retreat is the only expensive outcome.

A stonemason's yard at dusk with a finished block smashed back to rubble in front of an identical newly cut block, and more rough blocks receding behind

When you are actually allowed to care about any of this

Most articles on this subject go wrong here, so let it be explicit: almost no code should be written against these heuristics.

The thresholds above are flags. The elements-kind lattice has grown an exception. A number that was authoritative in 2015 is one shipping release away from being wrong. Code contorted to keep an object monomorphic is code optimised against a moving target, and it will be read for years by people who cannot tell which contortions were load-bearing. V8 says so itself, in the middle of the very post that explains the lattice: “In most cases, elements kind tracking works invisibly under the hood and you don’t need to worry about it.”

A mostly bare watchmaker's bench with a fine precision tool lying unused in its velvet case at one end and one ordinary task under way in a pool of lamplight

What genuinely justifies the effort is narrow and recognisable:

  • A hot loop you have profiled, where a specific function is a measured fraction of wall-clock time — not a function you suspect is hot.
  • A performance cliff that gets worse rather than better with load, which is the opposite of what “the engine has more feedback now” predicts, and is the signature to look for.
  • A shape you can stabilise without distorting the design — initialising every field in the constructor, avoiding a late delete, keeping a numeric array numeric. These are cheap and they read as ordinary care rather than as engine appeasement.

Everything past that line is a diagnostic exercise, and the diagnostics exist. --trace-opt, --trace-deopt and --log-deopt are declared in V8’s flag file with the DEFINE_DEVELOPER_FLAG macro, which appends “(developer-only)” to the description and defaults them to false; --log-ic, which logs inline-cache state transitions, is a plain boolean, also false. The engine will tell you. It will not volunteer.

Guess, keep a way back, and pay when you are wrong

Strip the JavaScript out and what remains is a pattern that any engineer will meet again in a system that has never heard of a hidden class. Anything that speculates must be able to undo, and the interesting question about such a system is never whether it guesses — it is what a miss costs and who finds out.

SystemThe guessWhat a miss costsWho is told
Speculative JITThis site keeps seeing one shape or typeThe optimised code is discarded; execution resumes in a lower tier and re-earns promotionNobody — unless a developer-only flag was set before the run
CPU branch predictionThis branch goes the way it went recentlyA pipeline flush and restart at the branchNobody, in-program — visible only as aggregate counters
Postgres plan cachingA cached generic plan is close enough for these parametersA plan chosen for the wrong data distribution, or a replanNobody — the query returns correct rows, slowly
Optimistic concurrencyNo conflicting write will land before commitThe whole transaction is rolled back and must be redoneThe caller, as an error it is required to handle
A speculative agent tool callThe next step is probably this tool with these argumentsA discarded call, latency, tokens, and any side effect already committedDepends entirely on whether the call was reversible
A row of five identical brass service bells, four hanging still with their wires cut and dangling, one swinging on a taut wire that runs away into the light

Two rows in that table are worth putting side by side, because they are the extremes.

PostgreSQL’s planner runs a warm-up counter that would look familiar to anyone reading a JIT: its PREPARE documentation states that “The current rule for this is that the first five executions are done with custom plans and the average estimated cost of those plans is calculated” before a generic plan is considered. Five executions of evidence, then a cached decision — a coarse, long-lived inline cache keyed on query shape. When the cached decision is bad, your query is slow and correct, and nothing raises its hand.

Optimistic concurrency is the opposite, and it is the honest one. PostgreSQL’s isolation documentation does not hide the cost of a lost bet — it makes it your problem in writing: “applications using this level must be prepared to retry transactions due to serialization failures.” The speculation is identical in spirit; the difference is that the miss surfaces as an error code that the calling application is obliged to handle.

A JIT deopt sits at the silent end for a defensible reason — it changes nothing observable, so there is nothing the language could correctly report. But that reasoning has a limit, and the limit is worth naming when you build your own speculative layer. A design that guesses is a design that must answer three questions, not one: is the undo correct, is the undo cheap enough to repeat, and is anybody told. Engines answered the first two decades ago. The third they answered by handing you a flag and switching it off.

❓ Frequently Asked Questions

Is a deoptimization a bug in my code? No. A deopt is a correctness mechanism doing exactly its job — it exists so the engine is allowed to gamble on your types in the first place. A single deopt in a function’s lifetime is normal and cheap. What is worth investigating is a deopt that repeats: the same function bailing out again and again means the engine keeps making a bet your code keeps refusing, and you are paying for compilations you never get to use.

How do I actually see deoptimizations? Pass V8’s tracing flags to whatever embeds it: node --trace-deopt --trace-opt yourscript.js prints a line for every promotion and every bailout, with a reason string like wrong map or Insufficient type feedback for generic named access. Chrome accepts the same flags via --js-flags. They are off by default and marked (developer-only) in V8’s own flag definitions, so nothing you ship will emit them by accident.

Is it true that four object shapes at one call site make it megamorphic? The “4” comes from a 2015 blog post by a former V8 engineer, not from V8’s documentation, and it is a tunable setting rather than a language rule. Checked on 9 September 2026, Node v22.18.0’s --max-valid-polymorphic-map-count did default to 4 — while V8’s own main branch defined the default as 10. Read it from the runtime you are actually shipping (node --v8-options) rather than from any article, including this one.

Does delete deoptimize my code? Not directly, and the framing is slightly off. Adding and deleting many properties can push an object into V8’s dictionary (“slow properties”) representation, which is a different thing from a bailout: the object has not been deoptimised, it has left the shape-tracking system altogether and will not be the subject of a shape guess again. The related and stricter rule is elements kinds — put one float or one hole in an array of small integers and, per V8, it stays marked that way “forever”, with a narrow Array.prototype.fill exception added in 2025.

Should I write my code to keep objects monomorphic? Mostly no. Engine heuristics move between releases, so code contorted around them ages badly and reads as superstition to the next maintainer. What is worth doing costs nothing and looks like ordinary care: initialise every field in the constructor, keep numeric arrays numeric, avoid a late delete on a hot object. Go beyond that only for a specific function you have profiled and measured — not one you suspect.

Do other engines deoptimize the same way? The mechanism is universal; the vocabulary and the policy are not. JavaScriptCore calls it OSR exit and argues that “deoptimization” is the wrong word, and it manages repeat offenders with a published exponential back-off — each recompilation doubles the evidence required. V8 attacked the same failure mode historically by replacing the compiler that suffered from it. Any article that says “the JavaScript engine” and means V8 is describing one implementation’s choices, not the language.


📚 Recommended Resources

Disclosure: some links below are Amazon affiliate links. If you buy through them, this site earns a small commission at no extra cost to you.

On this particular subject the free primaries are not a consolation prize — they are better than the books, because they are dated and the books are not. Start with them.

Read the engines arguing with each other. Speculation in JavaScriptCore is the single most complete public account of how a speculative engine decides when to compile, when to bail out, and how hard to back off afterwards — including the published exponential-backoff thresholds quoted above. Read it next to V8’s Speculative Optimizations for WebAssembly using Deopts and Inlining and Land ahoy: leaving the Sea of Nodes, and you get two teams describing the same mechanism with different vocabulary, different policies and one shared conclusion about repeat offenders.

Then read the two V8 posts about what breaks the bet, Elements kinds in V8 and Fast properties in V8. Both are from 2017 and both are still the clearest published accounts anywhere. Check the inline update notes before you quote either — the elements-kind lattice acquired a carve-out in 2025 that exists only as an appended line.

Read the origin paper, which is free and short. Debugging Optimized Code with Dynamic Deoptimization (Hölzle, Chambers and Ungar, PLDI ’92) is where the word comes from, and it was a debugging paper. Knowing that changes how the mechanism reads.

And check the numbers yourself rather than citing an article. node --v8-options prints every V8 flag your runtime actually has, with its actual default, and src/flags/flag-definitions.h shows what V8’s tree currently defines. Those two disagreeing is the most useful thing in this article.

Books worth the shelf space

  • Engineering a Compiler — Keith D. Cooper and Linda Torczon, Elsevier. The best single text on what an optimising compiler is actually doing when it commits to an assumption, and therefore on what it has to record in order to take that assumption back.
  • Computer Architecture: A Quantitative Approach — John L. Hennessy and David A. Patterson, Morgan Kaufmann. For the row of the table this article could only sketch: branch prediction, speculative execution and misprediction penalties, treated with real measurement rather than analogy.
  • Designing Data-Intensive Applications — Martin Kleppmann, O’Reilly. The chapter on transactions is the best available treatment of optimistic concurrency as a deliberate bet with a published price — the one system in this article’s comparison that tells the caller when it loses.
  • Database Internals — Alex Petrov, O’Reilly. Where the query-plan half of the analogy lives: how a planner commits to a decision from statistics that are already stale, and what it does when the data drifts away from what it assumed.

Newsletter

Tags

Agentic AI Agentic Development AI AI Agents AI Coding Agents Artificial intelligence Backlinks Blockchain Bounded Context Browser ChatGPT Plugins Claude Code CLPR Context Engineering Cross-Chain Cryptocurrency Developer Tools Distributed Systems DLT Domain-Driven Design Email Google Search HBAR Hedera Hedera Hashgraph Internet LLM Machine Learning MCP Microservices Model Context Protocol News NLP Orchestration Photoshop Productivity Saga Pattern SEO Smart Contracts Software Architecture software engineering Twitter Typescript Web3 Writing

Recent Articles

  • QUIC and HTTP/3: The Protocol That Won Is Being Routed Around October 11, 2026
  • The Mark I Perceptron: The Machine That Learned to See October 11, 2026
  • GraphRAG vs Vector RAG: Knowledge Graphs for AI, Explained October 11, 2026
  • Cardano Tokenomics: How ADA’s Supply Actually Works October 10, 2026
  • Transformer Encoder vs Decoder: How to Read the Diagram October 10, 2026
  • Nobody Checks the Verified Badge: The Real State of Git Commit Signing October 10, 2026

Copyright © 2026 · News Pro Theme On Genesis Framework · WordPress · Log in

Content on this site is produced with the assistance of Large Language Models and edited by a human. It is intended for educational purposes — facts may occasionally be incorrect. If you spot an error, please email Larionovdmitri@learninternetgrow.com.

Affiliate Disclosure: This site contains affiliate links to Amazon and other partners. If you purchase through these links, LearnInternetGrow.com may earn a commission at no additional cost to you. We only recommend products we genuinely believe in.

Privacy Policy · Terms of Service