JS Ledger

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.

6 min read
A steel indexing machine on a workshop bench: long guide rails carrying square stops converge on a rack-and-pinion carriage, with one small brass gear at its centre.

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.

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 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 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.

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.

// 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.

Watching it happen

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

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:

node --allow-natives-syntax
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 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, where a single long task costs more than every deoptimization in the process.

Frequently asked

Does adding a property later really change performance?
It changes the object's hidden class, which changes what the call sites reading that object see. Whether it costs anything measurable depends on whether those call sites were monomorphic before. Construct objects with the same properties in the same order and the question stops arising.
Should I write code to please the optimizer?
Almost never. Consistent object shapes and stable argument types are also ordinary good practice, and they are the only two habits worth adopting deliberately. Everything past that is unmeasured folklore with a real readability cost.
Share

Related posts

Arrow keys to move, Enter to open.