open atlas
↑ Back to track
Node.js, zero to senior NODE · 14 · 01

V8 optimization: hidden classes, inline caches, and the JIT tiers

V8 makes type-stable code fast by giving same-shaped objects one hidden class, caching property lookups monomorphically, and tiering hot functions up to TurboFan. Shape churn, deletes, and type-mixing trigger deopts and slow dictionary mode.

NODE Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

A hot request handler that parsed JSON and normalised it ran at 40k ops/s in the benchmark and 9k ops/s in production. Same code, same Node version. The benchmark fed it one object literal; production fed it five payload variants, and one path did delete obj.temp before returning. That single delete flipped the object into dictionary mode, the property-read inline cache went megamorphic, and TurboFan refused to keep the function optimised — it bailed back to the interpreter on every hot loop. The fix was three lines: build a fresh object with a stable shape instead of mutating-then-deleting. Throughput went back to 38k.

Hidden classes: objects are typed even though JavaScript isn’t

JavaScript has no static types, but V8 invents them at runtime. Every object points to a hidden class (V8 calls it a Map, also documented as a Shape) that records the object’s layout: which properties exist, in what order, and at which memory offset each one lives. Two objects created the same way — same properties added in the same order — share the same hidden class, so V8 can compile a property access into “read the slot at offset 16” instead of a hash-map lookup.

Hidden classes are built by transitions. Start with {} (the empty hidden class C0); add x and you transition to C1; add y and you transition to C2. V8 caches these transitions in a tree, so the next object that adds x then y walks the same path and lands on the same C2 — no new class allocated. This is why object construction order is load-bearing.

function makePoint(a, b) {
  const p = {};
  p.x = a;   // C0 -> C1
  p.y = b;   // C1 -> C2
  return p;
}
// Both share hidden class C2 — V8 treats them as one "type":
const p1 = makePoint(1, 2);
const p2 = makePoint(3, 4);

// But add the SAME properties in a different ORDER and you get a
// different transition path — a SECOND hidden class for the same fields:
const q = {};
q.y = 2;   // C0 -> C1'
q.x = 1;   // C1' -> C2'   (C2' != C2)

Three things break shape sharing and you must know all three:

  • Adding properties in a different order produces a divergent transition path and a distinct hidden class, even though the field set is identical.
  • delete obj.prop is the worst: V8 cannot represent a “hole” in a fast packed layout, so it converts the object to dictionary mode (slow properties) — a hash map per object, with no shared hidden class and no inline-cache benefit. Use obj.prop = undefined or rebuild the object if you truly need the key gone.
  • Mixing value types in the same slot (an integer where a double or object usually lives) changes the field’s representation and forces a hidden-class change; for arrays it triggers an elements-kind transition (PACKED_SMI → PACKED_DOUBLE → PACKED_ELEMENTS, each strictly more general and less optimisable).

Together these three shape-breakers push an object off the fast path permanently — once in dictionary mode, there is no automatic recovery. Miss any one of them on a hot object and you pay the hash-map tax on every property read in that function.

Inline caches: monomorphic, polymorphic, megamorphic

A hidden class is only half the machinery. The other half is the inline cache (IC): at each property-access site — obj.x in some function — V8 remembers which hidden class it saw and the offset it resolved to. Next time, if the object has the same hidden class, it reads the cached offset directly. The IC’s state is the single best predictor of how fast that site runs.

function dist(p) {
  return Math.sqrt(p.x * p.x + p.y * p.y); // the IC lives at p.x / p.y
}
  • Monomorphic — the site has only ever seen one hidden class. The IC is a single compare-and-read; this is the fast path and the one TurboFan can inline and speculate on hardest.
  • Polymorphic — the site has seen 2–4 hidden classes. V8 keeps a small list and linearly checks each; slower, but still optimisable.
  • Megamorphic — more than ~4 shapes. V8 gives up on the per-site cache and falls back to a generic megamorphic stub (a global hash lookup). This is the cliff: it is dramatically slower and blocks the function from being well-optimised.

The practical rule: feed each hot call site objects of one shape. A function that normalises three different payload shapes should be three monomorphic functions (or normalise to one shape before the hot path), not one megamorphic one.

Why this works

You can peek at the IC/optimisation state in development with natives syntax: run node --allow-natives-syntax and call %GetOptimizationStatus(fn) or print IC states with --trace-ic. This is a diagnostic peek only — never ship it, never branch production logic on it. %-functions are an internal V8 contract that changes between versions, and --allow-natives-syntax disables safety assumptions. Use it to confirm a hypothesis (“is this function actually optimised?”), then delete the flag.

JIT tiers: Ignition, Sparkplug, Maglev, TurboFan

V8 does not compile your code to optimised machine code up front — that would waste compile time on code that runs once. Instead it tiers up based on how hot a function is, trading compile speed for code quality at each step.

TierRoleSpeed tradeoff
IgnitionInterpreter. Compiles JS to bytecode, runs it, and gathers type feedback (hidden classes seen at each IC).Instant start, slowest execution.
SparkplugBaseline JIT. Bytecode → machine code near-instantly, no optimisation; ~45% faster than the interpreter.Very fast compile, modest speedup.
MaglevMid optimiser (SSA + feedback). Much faster code than Sparkplug, compiled far faster than TurboFan.Balanced.
TurboFanPeak optimiser. Speculative, type-specialised machine code based on stable feedback; inlines aggressively.Slow compile, fastest execution.

TurboFan’s speed comes from speculation: it assumes p is always shape C2 and bakes that assumption into the code, dropping all the type checks. When the assumption holds, it flies. When it breaks — a C3 object shows up, a slot’s type changes — the speculation is invalid and V8 deoptimizes: it discards the optimised code and drops the function back to the interpreter to re-collect feedback. A few deopts are normal warm-up; a function that deopts repeatedly (a deopt loop) is stuck paying interpreter prices on a hot path.

The triggers you control:

// 1. Hidden-class churn: this site sees C2, then C2', then dictionary-mode
//    objects -> goes polymorphic, then megamorphic, then deopts.
function sum(o) { return o.x + o.y; }

// 2. `arguments` leaking out of a function used to defeat optimisation;
//    even today, prefer rest params, which V8 reasons about cleanly:
function bad() { return Array.prototype.slice.call(arguments); } // avoid
function good(...args) { return args; }                          // optimisable

Historically try/catch and arguments misuse blocked optimisation outright; modern TurboFan handles try/catch fine, but shape churn and type instability remain the reliable deopt triggers. Keep the shapes and types a hot function sees constant and it climbs to TurboFan and stays there.

Garbage collection: why short-lived allocations are cheap

V8’s heap is generational, built on the empirical fact that most objects die young. New objects go in the young generation (a small space split into two semi-spaces, from and to). Collection there — the Scavenge — is a cheap copying collector: it copies the still-live objects from from to to, then declares the whole from space free in one move. Because it only touches survivors and most objects are already dead, a Scavenge is fast and frequent.

Objects that survive a couple of Scavenges are promoted to the old generation, collected by Mark-Sweep-Compact: mark everything reachable, sweep the dead, occasionally compact to fight fragmentation. This is the expensive, less frequent collector. The takeaway for hot code: a short-lived allocation inside a request — a temporary array, an intermediate object — is genuinely cheap because it dies in the young space and is reclaimed by a fast Scavenge. What hurts is promoting garbage: holding references that keep objects alive just long enough to reach the old generation, where collecting them is costly. Pool or reuse only when profiling proves a real Scavenge/promotion problem — premature object pooling often adds old-gen pressure.

Quiz

A hot function reads obj.id, and the call site has seen 7 different hidden classes. What is the IC state and the consequence?

Quiz

Why does `delete obj.temp` on a hot object hurt far more than setting `obj.temp = undefined`?

Order the steps

Order V8's execution tiers from first-run to peak-optimised:

  1. 1 Ignition — interpret bytecode, gather type feedback at each IC
  2. 2 Sparkplug — baseline JIT, bytecode to machine code near-instantly
  3. 3 Maglev — mid-tier optimiser, SSA + feedback, fast to compile
  4. 4 TurboFan — peak speculative optimiser on stable shapes/types
Recall before you leave
  1. 01
    Walk through exactly why a function that processes five differently-shaped payloads runs far slower than the same logic over one shape — name the hidden-class, IC, and tier mechanisms.
  2. 02
    Why are short-lived allocations in a request handler cheap, and what is the allocation pattern that actually hurts GC?
Recap

V8 invents types for type-less JavaScript: every object points to a hidden class recording its exact layout, and objects built the same way — same properties, same order — share one hidden class, so a property access becomes a direct offset read. At each access site an inline cache caches the shape it sees: monomorphic (one shape) is the fast path, polymorphic (2–4) is tolerable, and megamorphic (more than ~4) is the cliff where V8 falls back to a generic hash lookup. Hot functions tier up — Ignition interprets and gathers feedback, Sparkplug emits baseline machine code, Maglev mid-optimises, and TurboFan produces peak speculative code that assumes stable shapes and types; when that assumption breaks it deoptimizes back to the interpreter. The reliable deopt triggers you control are shape churn: adding properties out of order, delete (which drops an object into slow dictionary mode), and mixing value types in a slot or array. Use obj.prop = undefined over delete, normalise payloads to one shape before the hot path, and prefer rest params over leaking arguments. On memory, the heap is generational: short-lived allocations die in the young space and are reclaimed by a cheap, frequent Scavenge, so small per-request objects are nearly free — what hurts is promoting garbage into the old generation, collected by costly Mark-Sweep-Compact. Peek at optimisation state with node --allow-natives-syntax and %GetOptimizationStatus only as a diagnostic, never in production. Type-stable, stable-shaped, monomorphic code is fast code. Now when you see a hot function underperforming in production but not in benchmarks, the first question is: how many shapes does each call site see, and does any path use delete?

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 5 done

Something unclear?

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

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

Trademarks belong to their respective owners. Editorial reference only.