Web Platform · JavaScript Runtime
Object Shapes, Inline Caches, and the Cost of a Polymorphic Site
Why property access is fast when objects share a hidden class, how shapes transition, and what turns an inline cache from a lookup into a hash probe.
A JavaScript object has no fixed layout in the language. Properties can be added, reordered, and deleted
at any time, and the prototype chain can change underneath an access. An engine that treated every
obj.name as a hash table probe would spend most of its time hashing strings, so production engines do
the opposite: they give each object a shape describing where its properties live, and they specialize
the code at each property-access site on the shape it actually saw. The specialization is an inline
cache, and its behaviour explains most of the performance differences between two loops that look
identical.
The shape is a separate object
An object in a modern engine is a pointer to a shape descriptor plus a block of storage. The shape — V8 calls it a map, SpiderMonkey a shape, JavaScriptCore a structure — records, for each property, whether the value is stored inline in the object or in an out-of-object properties array, and at which offset. It also carries the prototype reference, flags such as extensibility, and, importantly, a transition table mapping (property name, attribute) pairs to other shapes.
That transition table is what makes construction cheap and predictable. Starting from an empty object,
adding x moves to a shape that includes x at a known offset; adding y next moves to a second shape.
Two objects built by the same sequence of additions share the same shape object, and the second
construction can reuse the transition instead of recomputing layout. Two objects that receive the same
property names in a different order end up with different shapes:
function Point(x, y) { this.x = x; this.y = y; }
function PointReordered(x, y) { this.y = y; this.x = x; }
new Point(1, 2); // shape A: x then y
new PointReordered(1, 2); // shape B: y then x — different map, different offsets
The properties are identical in name; the storage layout is not. Code that receives both kinds of object at one access site sees two shapes and cannot optimize for one, even though the source of each object is a single line apart. Constructors that assign fields in a fixed order, and class fields that declare them explicitly — the initialization order of which is covered in execution contexts and closures — produce a single shape for every instance of the class.
Inline storage is limited. The engine reserves space for a fixed number of properties in the object header; beyond that, values move to a separate backing store. Objects with a handful of properties keep them inline and need no extra indirection; objects with dozens pay one dereference per access to out-of-line storage. Deleting a property is the expensive case: rather than renumbering offsets, engines convert the object to dictionary mode, a shape-less hash table that supports removal. An object that has been converted stays slow for the rest of its life, and the conversion is per object, not per shape.
What the site remembers
An inline cache is per access site — one syntactic obj.name in the source, not one per object. The
first time the site executes, it records the shape it saw and the offset where name was found in that
shape. On subsequent executions it compares the incoming object’s shape pointer with the recorded one; a
match means the property is at the recorded offset, and the access is a compare and a load.
The state of a site falls into three broad categories:
- Monomorphic — one shape seen. The check is a single pointer comparison, and the optimizing compiler can hoist it, inline the load, and even scalar-replace the object.
- Polymorphic — a small number of shapes, conventionally up to four, each with its own cached offset. The site performs a short sequence of shape comparisons; cost grows linearly with the count but stays small.
- Megamorphic — more shapes than the cache can hold. The site gives up on specialization and falls back to a generic lookup that consults a global cache keyed by shape and name, or walks the prototype chain directly.
The transition from polymorphic to megamorphic is the one that shows up in profiles: a function written against a single interface becomes slow when passed a dozen different object types, even though every argument is structurally valid. The fix is not a micro-optimization of the property access; it is to reduce the number of distinct shapes reaching the site — by normalizing constructors, by converting heterogeneous records to a common class at the boundary, or by splitting one function into several.
Elements have their own kind system
Numeric properties, and arrays generally, are not stored through the property shape. They live in elements, and the engine tracks an element kind that describes what the array can hold. V8’s set is representative: packed small-integer elements, packed double elements, packed tagged elements, each with a holey variant once the array contains a gap. The kinds form a one-way lattice. An array of small integers that receives a non-integer promotes to doubles; one that receives an object promotes to tagged values; one that gets a hole becomes holey. Promotion is permanent: removing the offending value does not restore the earlier kind.
new Array(n) creates a holey array with length n and no elements, and filling it by index keeps it
holey. [] followed by push stays packed, which is why the literal-plus-push pattern is preferred for
building arrays whose size is not known in advance. Mixing small integers and doubles in the same array
forces the double representation: every element is then stored as an unboxed 64-bit double, including
the integers that would otherwise stay packed as small integers. The trade is space for the integers
against a uniform layout, not a universal win.
Property enumeration has an independent, normative order: integer-index keys in ascending numeric order, then string keys in insertion order, then symbol keys in insertion order, as specified by OrdinaryOwnPropertyKeys. The order does not depend on the shape, and engines implement it consistently. Code that relies on it is not relying on an implementation detail, though code that relies on an order between numeric and string keys having any logical meaning is.
Prototypes are part of the shape
An object’s prototype is recorded in its shape, and a property access that misses the object walks to the
prototype’s shape. The inline cache therefore also caches where the property was found: on the receiver,
on the first prototype, or further up. Changing a prototype with Object.setPrototypeOf or by assigning
to __proto__ invalidates caches that assumed the old chain, and engines treat it as a slow path for
that reason. Adding a property to a prototype object also transitions that object’s shape and invalidates
any inline cache that had recorded a miss.
The classic array-prototype pollution bug is the same mechanism in reverse: code that enumerates
inherited properties sees whatever was added to Array.prototype, and inline caches for iteration sites
that previously had no such property must be re-evaluated. Object.create(null) avoids the chain
entirely, at the cost of losing hasOwnProperty and friends. The same chain is what instanceof walks
when it compares a prototype against a constructor, which is why the check fails for objects built in
another realm even when
their layout is identical.
When the optimizer has to give up
The optimizing compiler specializes code on the shapes and element kinds observed during warm-up. If a new shape arrives at a previously monomorphic site, the optimized code fails its guard and the engine bails out to the interpreter, where it collects the new shape and may re-optimize later. Deoptimization is not a correctness event and is not rare; it is the mechanism by which speculative optimization stays sound. Frequent deoptimization at one site indicates that the site genuinely sees many shapes, and no amount of rewriting will make it monomorphic unless the inputs change.
The practical rules that fall out of all this are conservative and few: construct objects with the same property order and complete them in the constructor rather than adding fields later; keep arrays homogeneous in element type and avoid creating holes; do not delete properties from objects that remain in use; prefer a small, fixed set of shapes at any hot call site. None of these require knowing a specific engine, because each one follows from the general structure of shape-based object models. The numbers behind the general advice vary by engine and version; the direction does not.
References
- Fast properties in V8 — V8 team
- JavaScript Engine Fundamentals: Shapes and Inline Caches — Mathias Bynens and Benedikt Meurer
- ECMAScript Language Specification — OrdinaryOwnPropertyKeys — Ecma International