Skip to content

Hidden Classes & Object Shapes

advanced18 min read

The Performance Cliff You Can't See

You'd think two objects with the same properties would behave identically. They don't. Not even close:

const a = {}; a.x = 1; a.y = 2;
const b = {}; b.y = 2; b.x = 1;

function sumXY(obj) { return obj.x + obj.y; }

// Feed the function both objects
for (let i = 0; i < 1_000_000; i++) {
  sumXY(i % 2 === 0 ? a : b);
}

Both a and b contain { x, y }. Same properties. Same values. But V8 treats them as structurally different objects because the properties were added in different order. This alternating access forces sumXY into polymorphic inline cache territory — measurably slower than if every object shared one shape.

Here's the thing most people miss: the order you add properties is baked into the object's identity at the engine level. Let's dig into why.

What V8 Calls a "Map" (Not That Map)

Mental Model

Picture a blueprint for a house. The blueprint says: "bedroom is behind door 1, kitchen is behind door 2." Every house built from the same blueprint has rooms in the same place. V8's hidden class (called a Map internally — unrelated to the Map data structure) is the blueprint for an object. It tells V8: "property x is at memory offset 0, property y is at offset 1." Property access becomes a direct memory read at a known offset instead of a dictionary lookup. Every object built the same way shares the same Map.

So what does this actually look like under the hood? Internally, a V8 JSObject is structured like this:

JSObject:
  [Map pointer]       → Hidden class (shape descriptor)
  [Properties pointer] → Backing store (overflow properties)
  [Elements pointer]   → Indexed properties (array-like)
  [In-object slot 0]   → First named property value
  [In-object slot 1]   → Second named property value
  ...

The Map contains:

  • Descriptor array: property names, their types, and their offsets
  • Prototype link: the object's __proto__
  • Instance size: how many in-object slots exist
  • Transition table: links to other Maps when properties are added
Quiz
Why is V8 property access fast despite JavaScript being dynamically typed?

Shape Transitions: Building the Map Chain

Every time you add a property, V8 doesn't just store the value — it creates a whole new Map. Watch what happens when you build an object one property at a time:

const point = {};     // Map M0: {} (empty object)
point.x = 1;         // Map M1: { x: @offset0 }
point.y = 2;         // Map M2: { x: @offset0, y: @offset1 }
Execution Trace
Creation
const point = {}
V8 assigns Map M0 — the root Map for plain objects
Add x
point.x = 1
Transition M0 → M1. M1 records: x at offset 0
Add y
point.y = 2
Transition M1 → M2. M2 records: x at offset 0, y at offset 1
Final layout
[Map:M2][x:1][y:2]
Accessing point.x reads memory at object + fixed offset. No lookup.

And this is where it gets interesting — the transitions are shared and cached. The second object that follows the same path reuses the same Maps:

const p1 = {}; p1.x = 10; p1.y = 20; // Walks M0 → M1 → M2
const p2 = {}; p2.x = 30; p2.y = 40; // Reuses the same chain — no new Maps created

V8 stores transitions in a table on each Map. When you write p2.x = 30, V8 checks M0's transitions for "x", finds M1 already exists, and moves directly there. This is how the system scales: unique shapes are created once, then shared.

Why Property Order Matters

This sounds like a minor detail, but stick with me — it's not. Reverse the property order and you get a completely different Map chain:

const forward = {}; forward.x = 1; forward.y = 2;   // M0 → M1(x) → M2(x,y)
const reverse = {}; reverse.y = 1; reverse.x = 2;   // M0 → M3(y) → M4(y,x)

forward has Map M2. reverse has Map M4. They have the same property names but different hidden classes because the offsets differ: in M2, x is at offset 0; in M4, x is at offset 1.

Common Trap

This doesn't just affect micro-benchmarks. Any function that receives both forward and reverse objects will have its inline caches polluted. If sumXY() first sees M2, it caches that shape. When M4 arrives, the IC transitions from monomorphic to polymorphic — up to 15x slower per property access. At scale across hot loops, this is the difference between 60fps and visible jank.

Quiz
What happens when two objects have the same properties but were initialized in different order?

Object Literals vs Dynamic Construction

Turns out, how you create objects matters just as much as what's in them. Object literals give V8 the complete shape upfront:

// V8 sees the full shape in the parser — jumps directly to the final Map
const p1 = { x: 1, y: 2, z: 3 };
const p2 = { x: 4, y: 5, z: 6 };
// p1 and p2 share the exact same Map — guaranteed

Dynamic construction walks intermediate Maps:

function makePoint(x, y, z) {
  const p = {};       // Map M0
  p.x = x;           // Map M1
  p.y = y;           // Map M2
  p.z = z;           // Map M3
  return p;
}

Both approaches produce consistent shapes (good), but the literal approach is faster because V8 allocates the object with the correct size from the start — no intermediate resizing, no transition chain walking.

V8's Literal Boilerplate Optimization

For object literals, V8 creates a boilerplate on the first encounter. Subsequent evaluations of the same literal clone the boilerplate — a single memcpy-style operation. This is significantly faster than stepping through property additions one at a time.

In-Object Properties vs Backing Store

You might assume all properties are stored the same way. They're not. V8 allocates a fixed number of in-object property slots when creating an object. For object literals, V8 can count the properties at parse time and size the object exactly. For dynamic construction, V8 uses heuristics (typically 4-10 initial slots based on the constructor).

When properties fit in-object:
JSObject: [Map][prop0][prop1][prop2]   ← single contiguous allocation

When properties overflow:
JSObject: [Map][prop0][prop1] → BackingStore: [prop2][prop3][prop4]
                                 ↑ extra indirection

Accessing an in-object property is one memory dereference. Accessing a backing-store property requires two: one to reach the backing store, one to read the property. The difference is small per access but compounds in tight loops.

How V8 decides in-object slot count

For classes, V8 uses the expected_nof_properties heuristic. After the constructor runs a few times, V8 observes how many properties are added and pre-allocates that many in-object slots for future instances. If you add properties outside the constructor (e.g., in a method called later), those properties may end up in the backing store because the in-object slots were already sized.

For object literals, V8 counts properties at parse time — the in-object slot count matches the literal's property count exactly. This is another reason to prefer literals for performance-critical code.

V8 also distinguishes fast properties (stored in-object or in a flat array) from slow/dictionary properties (stored in a hash table). An object transitions to dictionary mode when it has too many properties, when properties are frequently deleted, or when Object.defineProperty is used with non-default descriptors. Dictionary mode loses the benefits of hidden classes entirely.

Quiz
An object has 12 named properties added in the constructor. What likely happens?

The Transition Tree

V8's Maps form a transition tree — a trie-like structure where each edge is a property name:

M0 (empty)
├─ "x" → M1 (x)
│        ├─ "y" → M2 (x, y)
│        └─ "z" → M5 (x, z)
├─ "y" → M3 (y)
│        └─ "x" → M4 (y, x)
└─ "name" → M6 (name)
           └─ "age" → M7 (name, age)

Key properties of this tree:

  • Sharing: M0 is shared by all plain objects. M1 is shared by all objects whose first property is x
  • Path-dependent: M2 ({x,y}) and M4 ({y,x}) are different leaf nodes despite same property set
  • Lazy creation: branches are created only when a new initialization pattern is first observed
  • Memory: each Map is ~88 bytes in V8. A "Map explosion" from inconsistent object shapes wastes memory

Classes: Guaranteed Shape Consistency

If you're tired of worrying about property order, classes are your best friend. They solve the shape problem by design:

class Point {
  constructor(x, y, z) {
    this.x = x;   // Always first
    this.y = y;   // Always second
    this.z = z;   // Always third
  }
}

// Every instance gets the same Map, every time
const p1 = new Point(1, 2, 3);
const p2 = new Point(4, 5, 6);
const p3 = new Point(7, 8, 9);

The constructor enforces identical initialization order. V8 sees this after a few runs and pre-allocates exactly 3 in-object slots for all future Point instances.

Common Trap

Adding properties outside the constructor breaks this guarantee:

class User {
  constructor(name) {
    this.name = name;
  }
  setEmail(email) {
    this.email = email; // Some instances have email, some don't
  }
}

Users that called setEmail have a different Map than those that didn't. Initialize everything in the constructor:

class User {
  constructor(name, email) {
    this.name = name;
    this.email = email ?? null; // Always present, consistent shape
  }
}
Quiz
Why do classes produce better hidden class behavior than factory functions with conditional properties?

Production Pattern: The Shape Explosion

This is the part that burns people in production. Here's a real-world API normalizer that creates 2^N shapes:

function normalizeProduct(raw) {
  const product = {};
  product.id = raw.id;
  product.name = raw.name;
  if (raw.price) product.price = raw.price;
  if (raw.discount) product.discount = raw.discount;
  if (raw.category) product.category = raw.category;
  if (raw.imageUrl) product.imageUrl = raw.imageUrl;
  return product;
}

With 4 optional fields, this creates up to 16 different hidden classes. Any function processing an array of these products hits megamorphic ICs — the slowest possible path.

The fix is dead simple — always initialize every property, use null for absent values:

function normalizeProduct(raw) {
  return {
    id: raw.id,
    name: raw.name,
    price: raw.price ?? null,
    discount: raw.discount ?? null,
    category: raw.category ?? null,
    imageUrl: raw.imageUrl ?? null,
  };
}

One shape. Monomorphic access everywhere. The team that applied this fix to their product listing page measured a 2.8x throughput improvement in their data processing pipeline.

Quiz
A factory function conditionally adds 5 optional properties. In the worst case, how many distinct hidden classes can objects from this function have?

Key Rules

Key Rules
  1. 1Every object gets a hidden class (Map) describing property names, order, and offsets. Same initialization pattern = same Map.
  2. 2Property insertion order matters. {x, y} and {y, x} produce different hidden classes with different memory layouts.
  3. 3Object literals give V8 the full shape upfront — prefer them over incremental property addition.
  4. 4In-object properties are fastest (one dereference). Overflow properties use a backing store (two dereferences).
  5. 5Classes guarantee consistent shapes — every instance from the same constructor gets the same Map.
  6. 6Always initialize all properties, including optional ones (use null/undefined). Conditional properties create shape divergence.
  7. 7Deleting properties or using Object.defineProperty with non-default descriptors can push objects into slow dictionary mode.
1/9