# How V8 Decides to Optimize Your Function

> Two functions that look identical can run at very different speeds. The difference is usually shapes, inline caches, and a deoptimization you never saw.

- Published: 2026-07-09
- Tags: javascript, performance, v8
- Source: https://jsledger.com/blog/how-v8-decides-to-optimize-your-function/
- Language: en-US
- Author: Jonah Vail

---
Here are two functions. They compute the same thing, they are called the same
number of times, and one of them can run several times slower.

```js
function totalA(items) {
	let sum = 0;
	for (const item of items) sum += item.price;
	return sum;
}

function totalB(items) {
	let sum = 0;
	for (const item of items) sum += item.price;
	return sum;
}
```

The functions are identical. The difference is in what gets passed to them —
and to understand why that matters, you have to know what V8 is doing while
your program runs.

Everything below was checked on Node 24.18.0. Run `node -p process.versions.v8`
to see which V8 build your Node embeds; the tier names and flags have been
stable for several major versions, but exact output varies between builds.

## Your code is compiled more than once

V8 does not compile JavaScript once and run it. It runs the same function
through up to four tiers, promoting it only when the function proves it is
worth the effort:

**Ignition** is the interpreter. Every function starts here, as bytecode. It
compiles instantly and runs slowly, which is the right trade for code that
executes twice and is never seen again.

**[Sparkplug](https://v8.dev/blog/sparkplug)** is a baseline compiler. It walks the bytecode and emits machine
code almost mechanically, with no optimization and no type assumptions. It is
fast to produce and gives a solid speedup over interpretation.

**[Maglev](https://v8.dev/blog/maglev)** is the mid-tier optimizing compiler. It uses the type feedback
collected so far to generate decent code quickly, sitting between "no
assumptions" and "aggressive assumptions".

**TurboFan** is the optimizing compiler. It inlines, unrolls, escapes-analyses,
and specializes the code to the exact types the function has been seeing. It is
slow to run and produces the fast version.

The promotion decision is not "this function is hot" alone. It is "this
function is hot **and** we have consistent type feedback for it". Without the
second half, there is nothing to specialize on, and TurboFan would emit code
barely better than Sparkplug's.

## Where the feedback comes from

Every property access, every method call, every arithmetic operation in your
code has a hidden slot attached to it called an *inline cache*. The first time
`item.price` executes, V8 records what it saw: an object of a particular
internal shape, with `price` at a particular offset.

That shape is the object's **hidden class** — V8 calls it a Map, which is
unfortunate naming that has nothing to do with `Map`. Every object has one, and
objects that were built the same way share one.

```js
const a = { sku: 'x', price: 10 };
const b = { sku: 'y', price: 20 };
// a and b share a hidden class.

const c = { price: 30, sku: 'z' };
// c does not: the properties were added in a different order.
```

Hidden classes form a transition tree. Starting from the empty object, adding
`sku` moves to one class, then adding `price` moves to another. Build the two
properties in the other order and you walk a different branch and arrive
somewhere else — a class that describes the same properties but is not the same
class.

Once a call site has seen exactly one hidden class, it is **monomorphic**, and
V8 can compile `item.price` down to a bounds check plus a load at a fixed
offset. Show it a second shape and it becomes **polymorphic**, keeping a small
list of shapes to check. Past four, it goes **megamorphic** and falls back to a
generic lookup through a global cache — much slower, and no longer something
TurboFan can specialize.

That is the difference between `totalA` and `totalB`. If one of them is called
only with objects built by a single factory, and the other is called with items
assembled by three different code paths, they are not the same function to V8
even though they are the same function to you.

## What breaks it after the fact

The nastier version of this problem is *deoptimization*: the function was
already optimized, and then an assumption failed.

```js
// deopt-demo.mjs - the whole reproduction.
function total(items) {
	let sum = 0;
	for (const item of items) sum += item.price;
	return sum;
}

const items = Array.from({ length: 1000 }, (_, i) => ({ sku: `s${i}`, price: i }));
for (let i = 0; i < 2000; i++) total(items);   // hot, one shape, optimized

// Later, somewhere else entirely:
items[0].discount = 0.1;
for (let i = 0; i < 2000; i++) total(items);   // same call, different shape
```

Adding a property to an existing object transitions it to a new hidden class.
The call site in the loop had specialized on the old one. When the optimized
code runs and finds the wrong shape, V8 cannot fix it up in place — it throws
the optimized code away, rebuilds interpreter state from the machine frame, and
resumes in Ignition. That last part is called *deoptimization bailout*, and it
is not cheap.

Common triggers, roughly in order of how often they surprise people: adding or
deleting properties after construction; a value that was always a small integer
suddenly being a double or a string; a function that was always called with two
arguments being called with three; reading a property that does not exist and
getting `undefined` where a number was expected.

:::note
Deoptimization is not a bug and not always a problem. A function that
deoptimizes once and then re-optimizes on the new feedback has paid a small
one-off cost. The pathology is the loop: optimize, deopt, optimize, deopt.
:::

## Watching it happen

None of this needs to be inferred. V8 will tell you.

```bash
node --trace-opt --trace-deopt app.js
```

`--trace-opt` prints a line whenever a function finishes compiling, naming the
tier it landed in. `--trace-deopt` prints one whenever optimized code is
discarded, with a reason attached.

Running `deopt-demo.mjs` from the previous section under `node --trace-deopt`
on Node 24.18.0, filtered to the function in question:

```
[marking dependent code <Code MAGLEV> (<SharedFunctionInfo total>) (opt id 4)
  for deoptimization, reason: dependent prototype chain changed]
[bailout (kind: deopt-eager, reason: wrong map): begin. deoptimizing
  <JSFunction total ...>, <Code TURBOFAN_JS>, opt id 5, bytecode offset 33 ...]
```

The whole story in two lines. Adding the property invalidated code V8 had
already compiled, and the next call through the specialized path met a shape it
was not built for — `wrong map`. Note the tier: this function had reached
TurboFan before the assumption broke.

Reasons repay reading. `Insufficient type feedback for …` means a call site
never settled on anything to specialize for — a different problem with a
different fix. Grep the stream for the function you care about; a name appearing
there repeatedly is the signal.

For a single function you can ask directly:

```bash
node --allow-natives-syntax
```

```js
function hot(o) { return o.x + 1; }
for (let i = 0; i < 100000; i++) hot({ x: i });
console.log(%GetOptimizationStatus(hot));
```

The return value is a bitfield; decoding it means checking the constants for
your V8 build, which is why the flag output above is usually the friendlier
tool. `%NeverOptimizeFunction` and `%OptimizeFunctionOnNextCall` are useful in
the same session for isolating a single function's behavior.

Note the flag name: `--allow-natives-syntax` enables internal V8 intrinsics.
It is a debugging tool, never something to ship.

## What to actually do

Very little, most of the time. Two habits are worth having, and both are things
you would want anyway:

**Build objects the same way every time.** Initialize every property in the
constructor or the object literal, in a fixed order, including the ones that are
sometimes absent — `discount: 0` beats adding `discount` later. Classes give you
this for free, which is a better argument for them than most of the ones usually
offered.

**Keep argument types stable.** A function that receives a number on every call
except one is a function with a polymorphic call site.

Both habits matter more in code that allocates heavily — a fine-grained
reactive system, for instance, where [every signal is a graph
node](/blog/signals-are-not-magic/) and the same shapes are constructed
thousands of times.

What is not worth doing: rewriting readable code because a blog post from 2013
said `for` loops beat `forEach`, or restructuring an application around a
micro-benchmark. Micro-benchmarks are particularly bad at this subject, because
a benchmark harness that calls one function in a tight loop hands V8 exactly
the clean, monomorphic feedback that the real program does not have.

Measure the real thing, with `--trace-deopt` running. If the deopt stream is
quiet, the shapes are fine and the slow part of your program is somewhere else
entirely — which, in my experience, it usually is. Often it is not the code at
all but [the loop scheduling it](/blog/the-event-loop-you-think-you-know/),
where a single long task costs more than every deoptimization in the process.
