Hidden Classes and Shape Transitions
Same Properties, Different Speed
Pop quiz: here are two ways to create the same object. One is 7x faster to access in a tight loop. Can you guess which?
// Version A
function createA() {
const obj = {};
obj.x = 1;
obj.y = 2;
return obj;
}
// Version B
function createB() {
const obj = {};
obj.y = 2;
obj.x = 1;
return obj;
}
Both return { x: 1, y: 2 }. Both have identical properties. Sounds like they should be the same, right? But if you create 100,000 of each and access .x in a loop, Version A objects are accessed through a different hidden class than Version B objects. Mix them in the same array, and you destroy inline cache performance.
This is the hidden class system -- and honestly, it's the single most important thing to understand about V8 object performance.
So What Are Hidden Classes, Exactly?
Think of hidden classes as filing systems. When you create an object { x: 1, y: 2 }, V8 doesn't store x and y as string-keyed hash lookups. Instead, it creates a filing system that says: "x is in slot 0, y is in slot 1." Every object that was built the same way shares this filing system. Property access becomes a direct memory offset read — object[offset_for_x] — instead of a dictionary lookup. The filing system is the hidden class. V8 calls it a Map internally (not related to the Map data structure).
Under the hood, V8 represents every JavaScript object as:
JSObject:
[Map pointer] -> Hidden class (shape descriptor)
[Properties] -> Backing store for overflow/slow properties
[Elements] -> Indexed properties (array-like)
[In-object slot 0] -> Value of first named property
[In-object slot 1] -> Value of second named property
...
The Map (hidden class) contains:
- The property names and their offsets
- The object's prototype
- The object's element kind (for indexed properties)
- Transition links to other Maps
Shape Transitions: A Chain of Maps
Here's where it gets interesting. Every time you add a property to an object, V8 creates a transition from the current Map to a new Map. This builds a transition tree:
const point = {}; // Map M0: {} (empty object)
point.x = 1; // Map M1: { x: @offset0 }
point.y = 2; // Map M2: { x: @offset0, y: @offset1 }
Now here's the critical insight that makes this whole system fast: transitions are shared. If you create another object the same way:
const p1 = {}; p1.x = 10; p1.y = 20; // Follows exact same transition chain
const p2 = {}; p2.x = 30; p2.y = 40; // Reuses M0 -> M1 -> M2
Both p1 and p2 end up with the same Map M2. V8 only created the transition chain once. This is how V8 turns a dynamic language into something that behaves like a statically-typed struct.
Why Property Order Creates Different Shapes
This is the part that burns people. Now reverse the order:
const p3 = {}; p3.y = 50; p3.x = 60;
Turns out, this creates a completely different transition chain:
{} -> M0
.y -> M3 (y at offset 0)
.x -> M4 (y at offset 0, x at offset 1)
p3 has Map M4, not M2. Even though p3 has the same properties as p1, they have different hidden classes. V8 treats them as structurally different objects.
// These two objects have DIFFERENT hidden classes
const a = {}; a.x = 1; a.y = 2; // Map: {x:0, y:1}
const b = {}; b.y = 1; b.x = 2; // Map: {y:0, x:1}
function getX(obj) { return obj.x; }
// Monomorphic: fast (one shape)
getX(a); getX(a); getX(a);
// Polymorphic: slower (two shapes)
getX(a); getX(b); getX(a); getX(b);
This is why property insertion order matters for performance.
Object Literals vs Dynamic Construction
Good news: object literals with the same structure always get the same Map:
// All three share the same hidden class — V8 sees the literal shape
const p1 = { x: 1, y: 2 };
const p2 = { x: 3, y: 4 };
const p3 = { x: 5, y: 6 };
This is faster than dynamic construction because V8 doesn't walk through individual property additions. It sees the full shape upfront in the literal.
Compare with:
function makePoint(x, y) {
const p = {};
p.x = x;
p.y = y;
return p;
}
This still produces consistent shapes (good), but V8 has to walk the transition chain on each call. For hot paths, the difference is measurable.
Conditional property addition is a shape killer:
function createUser(name, age, email) {
const user = { name, age };
if (email) user.email = email; // Some users have email, some don't
return user;
}Users with email get Map A {name, age, email}. Users without get Map B {name, age}. Any function that processes a mixed array of these users hits polymorphic inline caches. Fix: always initialize all properties, use undefined for missing values.
function createUser(name, age, email) {
return { name, age, email: email || undefined };
}Now every user object has the same shape.
The Class Pattern: Shared Shapes by Design
You know what guarantees consistent shapes every time? Classes. The constructor always runs the same initialization sequence:
class Point {
constructor(x, y) {
this.x = x; // Always first
this.y = y; // Always second
}
distanceTo(other) {
return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2);
}
}
Every new Point(...) produces an object with the same Map. The distanceTo method lives on the prototype, so it exists once in memory, shared by all instances.
// 100,000 copies of distanceTo in memory — each literal gets its own function object
const points = Array.from({length: 100000}, (_, i) => ({
x: i,
y: i,
distanceTo(other) {
return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2);
}
}));
// 1 copy of distanceTo — shared via prototype chain
class Point {
constructor(x, y) { this.x = x; this.y = y; }
distanceTo(other) {
return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2);
}
}
const points = Array.from({length: 100000}, (_, i) => new Point(i, i));
The memory difference is dramatic: ~800MB vs ~8MB for 100,000 objects. The class version also has consistent shapes, enabling monomorphic inline caches everywhere.
Production Scenario: The Polymorphic Shape Explosion
Here's one that happens all the time. A team builds a REST API response normalizer:
function normalizeUser(raw) {
const user = {};
user.id = raw.id;
user.name = raw.name;
if (raw.email) user.email = raw.email;
if (raw.avatar) user.avatar = raw.avatar;
if (raw.role) user.role = raw.role;
if (raw.lastLogin) user.lastLogin = raw.lastLogin;
return user;
}
With 4 optional fields, this creates up to 16 different hidden classes (2^4 combinations). When the app processes a mixed array of users — some with email, some with avatar, some with both — every downstream function that accesses these objects hits megamorphic inline caches.
The fix:
function normalizeUser(raw) {
return {
id: raw.id,
name: raw.name,
email: raw.email ?? null,
avatar: raw.avatar ?? null,
role: raw.role ?? null,
lastLogin: raw.lastLogin ?? null,
};
}
Every user now has the same six properties in the same order. One hidden class. Monomorphic access everywhere. The team measured a 3x improvement in their data processing pipeline.
How V8 stores the transition tree
V8's transition tree is a trie-like structure. Each Map has a transitions table mapping property names to child Maps. When you add a property, V8 looks up the current Map's transitions for that property name:
- Found: Reuse the existing child Map (fast path)
- Not found: Create a new Map and add the transition (slow path, happens once per unique shape)
This means the first object to walk a particular initialization path pays the cost of creating Maps. All subsequent objects with the same pattern reuse them. In production, well-structured code creates a small, stable set of Maps. Poorly-structured code creates a "Map explosion" that wastes memory and prevents inline cache optimization.
V8 limits in-object properties to a fixed number (typically 10-20, depending on the object). Properties beyond this limit spill into a separate backing store array, which adds an extra indirection on access.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Adding properties to objects conditionally based on data Conditional properties create multiple hidden classes. A single shape enables monomorphic access | Always initialize all properties, use null/undefined for missing values |
| Creating objects with different property orders in different code paths { x, y } and { y, x } are different hidden classes even though they have the same properties | Use object literals or classes to guarantee consistent property order |
| Using object spread to merge partial objects: { ...defaults, ...overrides } Different spread orders or different override keys create different shapes | Be aware that spread order affects shape. Keep the pattern consistent across the codebase |
| Attaching methods directly on object literals in hot creation paths Each object literal with inline methods creates a new function object. Classes share one copy via prototype | Use classes or prototype assignment for shared methods |
Quiz: Shape Transitions
Key Rules
- 1V8 assigns every object a hidden class (Map) that describes its property names, order, and offsets. Same initialization pattern = same Map.
- 2Property insertion order matters.
{x, y}and{y, x}are different hidden classes with different memory layouts. - 3Object literals with the same structure share a hidden class immediately. Prefer literals over dynamic property addition.
- 4Classes guarantee consistent shapes — every instance from the same constructor gets the same Map.
- 5Always initialize all properties. Conditional property addition creates shape divergence, causing polymorphic inline caches.
- 6Shared methods belong on prototypes (classes), not on individual objects. 100K objects with inline methods = 100K function copies.