const foo

Hidden classes and inline caches: how V8 really handles your objects

JavaScript objects look like hash maps, but engines refuse to treat them that way. Understand shapes, transition chains, and inline caches — and the innocent-looking code that quietly de-optimizes yours.

6 min read3 exercisesadvanced

Open any JavaScript reference and it will tell you an object is "a collection of key–value pairs" — a dictionary. That's true semantically and false operationally. If V8 actually stored your objects as hash maps, every user.name would cost a hash, a probe, and a comparison. Instead, a hot property access in V8 compiles down to roughly two machine instructions: compare a pointer, load from a fixed offset.

This lesson is about the machinery that makes that possible — hidden classes (V8 calls them maps, SpiderMonkey calls them shapes) and inline caches — and about the specific, innocent-looking patterns in application code that break it.

The dictionary lie

Consider this object:

a simple object
const user = { name: "Ada", age: 36 };

A hash-map implementation would store "name" and "age" as keys next to their values, per object. V8 does something closer to what a C compiler does with a struct: it stores only the values in the object — "Ada" at offset 0, 36 at offset 1 — and puts the layout description somewhere else, shared by every object that has the same structure.

That layout description is the hidden class. Every object carries one pointer to it. Two objects created with the same properties in the same order point to the same hidden class, which is exactly what makes the optimization work: the engine can learn facts about a hidden class once and reuse them for millions of objects.

Transition chains: how hidden classes are born

Hidden classes aren't created from a whole object at once. They're built incrementally, one property at a time, forming a transition tree that the engine walks as your constructor executes:

each line moves the object to a new hidden class
const p = {};      // hidden class C0 (empty)
p.x = 1;           // C0 --"add x"--> C1
p.y = 2;           // C1 --"add y"--> C2

The crucial property of this tree: it's shared and deterministic. Every object that starts empty and gains x then y lands on the same C2. But order is part of the path — an object that gains y then x takes a different branch and ends on a different hidden class, even though the final objects are semantically identical.

Reorder the lines

Build a factory where every object shares one hidden class

Drag the lines into the order that makes this code work.

  1. function makeUser(name, age) {
  2.   user.age = age;
  3. }
  4.   return user;
  5.   user.name = name;
  6.   const user = {};

Two well-known consequences follow directly from the transition tree, and both show up in real codebases:

  • Conditional properties fork shapes. if (isAdmin) user.role = "admin" splits your user objects into two hidden-class populations.
  • delete is worse than forking. Deleting a property usually can't be expressed as a transition at all, so V8 gives up and converts the object to dictionary mode — an actual hash map, the slow thing you thought you had all along. Setting the property to undefined keeps the shape; delete destroys it.

Inline caches: the payoff

Hidden classes only pay off because of what call sites do with them. Take:

a hot access site
function getName(user) {
  return user.name;
}

The first time getName runs, V8 does the slow generic lookup — and then caches the result at the call site: "if the argument's hidden class is C2, the value lives at offset 0." That per-site cache is an inline cache (IC). On every later call, the check is a single pointer comparison against C2, followed by a load from a constant offset.

ICs degrade gracefully — and measurably — as they see more shapes:

| IC state | Shapes seen | Cost of user.name | | --- | --- | --- | | Monomorphic | 1 | pointer compare + fixed-offset load | | Polymorphic | 2–4 | linear scan over a few cached shapes | | Megamorphic | more | fallback to a global hash-based cache |

The cliff between polymorphic and megamorphic is real. Once a site goes megamorphic it generally never recovers, because the IC has no way to know the zoo of shapes has gone away. This is why a single utility function called with every object shape in your codebase — a generic get(obj, key), a logging helper, an ORM row mapper — can be slower than the same logic duplicated near each call site.

Spot the difference

Three point factories walk into a hot loop

All three produce objects with x and y. One of them makes every function that receives these points megamorphic-prone. Which?

Property order is observable — and so is the trap

Hidden classes are invisible from JavaScript, but the spec-mandated property enumeration order gives you a related senior-level gotcha for free: integer-like keys are always enumerated first, in ascending numeric order, and only then string keys in insertion order. If you've ever built an ordered map out of an object keyed by IDs, you've met this bug in production.

Predict the output

An object keyed by mixed keys

const obj = { b: 1, 2: "two", a: 3, 1: "one" };
console.log(Object.keys(obj).join(","));

What does this print?

What to actually do about it

Most of the time: nothing. V8's people have spent two decades making average code fast, and readability beats micro-optimization in code that runs once. The knowledge pays off when you own a genuinely hot path — a parser, a game loop, a row mapper processing millions of records. Then:

  • Initialize every property in the constructor, unconditionally, in one order. Use null/undefined for absent values instead of conditional assignment.
  • Never delete on a hot path. Assign undefined, or reach for Map when keys genuinely come and go.
  • Keep hot functions shape-monomorphic. If a function must handle several object kinds, consider splitting it per kind — or normalizing inputs to one shape at the boundary.
  • Measure before believing. node --trace-ic and %HaveSameMap in --allow-natives-syntax mode tell you what the engine actually sees; guesses don't.

The next lesson moves from memory layout to time: what the event loop actually schedules, and why await is not setTimeout(0).