This chapter follows From source code to machine code, which ended with type feedback driving the optimizing compilers. Most of that feedback is about objects: which shapes reach each property access. This chapter is about those shapes. The examples use V8 terms and V8’s debugging functions; the last section lists what holds in every engine.
Why property access needs help
The language defines obj.x as a lookup by name: check whether obj has an own property x, and if not, walk the prototype chain. Taken literally, that is a hash-table lookup per step, for every property read. Statically typed languages avoid it because the compiler knows each field’s offset in advance. JavaScript has no declared layout, and properties can be added or removed at any time.
Engines recover most of that speed with two mechanisms. Hidden classes describe an object’s layout at runtime, and inline caches remember, at each place in the code that reads a property, which layouts it has seen and where the property was.
Shapes and transitions
V8 calls a hidden class a map. SpiderMonkey and JavaScriptCore call it a shape or structure. A map records which properties an object has, in which order, and at which offset each value is stored. The values live in the object; the map is shared by every object with the same layout.
Maps form a transition tree. An empty object starts with the root map. Adding x moves it to a map with x, and adding y to a map with x then y. A second object that adds the same properties in the same order follows the same transitions and ends with the same map.
flowchart LR
empty["{}"] -->|"add x"| mx["{x}"]
mx -->|"add y"| mxy["{x, y}"]
empty -->|"add y"| my["{y}"]
my -->|"add x"| myx["{y, x}"]Order matters, because the transitions differ. V8’s %HaveSameMap function, available with --allow-natives-syntax, compares two maps:
const a = { x: 1, y: 2 };const b = { x: 3, y: 4 };const c = { y: 5, x: 6 };
console.log(%HaveSameMap(a, b)); // trueconsole.log(%HaveSameMap(a, c)); // falsenode --allow-natives-syntax shapes.jsThe same applies to constructors and classes. Assigning a property in only some constructor calls splits the instances into two maps:
class User { constructor(name, email) { this.name = name; if (email) this.email = email; }}
const ada = new User('Ada', 'ada@example.com');const bob = new User('Bob');console.log(%HaveSameMap(ada, bob)); // falseAssigning the property every time, with this.email = email ?? null, or declaring it as a class field, gives every instance the same map. Class fields are initialized in declaration order on every construction, which is why they produce consistent shapes without any extra care.
Field representations
A map also records how each field is stored. V8 distinguishes small integers, doubles, references to heap objects, and “anything”. Optimized code relies on that: if w is known to hold small integers, rect.w * rect.h can load the raw value and multiply it without checking for strings or objects.
Storing a value of another kind does not create a new map for that one object. V8 generalizes the field for the whole shape, and every object with that map now stores w in the more general form. Optimized code that depended on the narrow form is deoptimized. That is the dependent field representation changed deoptimization in the trace from the previous chapter.
const r1 = { w: 1, h: 2 };const r2 = { w: 'wide', h: 2 }; // w is now "anything" for every { w, h } objectA field that always holds the same kind of value stays in its most efficient form. Mixing numbers and strings in one property, or a number and null used as “not set yet” in a hot numeric field, makes it general for all objects of that shape.
Dictionary mode
Some operations make an object leave the transition tree entirely. Its properties move into a hash table owned by the object — V8 calls this dictionary mode, or slow properties. Lookups still work, but there is no shared layout for inline caches to specialize on.
The most common trigger is delete:
const point = { x: 1, y: 2, z: 3 };console.log(%HasFastProperties(point)); // true
delete point.x;console.log(%HasFastProperties(point)); // falseIn Node 24, deleting even the last-added property of an object literal switched it to dictionary mode. Adding many differently named properties at runtime does the same: an object used as a hash map with 40 generated keys ended in dictionary mode as well.
If you need to remove a value from an object that stays in a hot path, set it to undefined instead. If the keys are data — user IDs, cache keys, anything not known when you write the code — use a Map. It is designed for frequent insertion and deletion and does not pretend to be a record. See Map and WeakMap.
Inline caches
Each property access and each call in your code has its own inline cache (IC), stored in the function’s feedback vector. The first time p.x runs, the cache records the map of p and the offset where x was found. The next time, if the map matches, the engine reads that offset directly and skips the lookup.
The cache moves through states as it sees more maps:
| State | Maps seen at this site | Cost |
|---|---|---|
| Uninitialized | None yet | First run performs a full lookup |
| Monomorphic | One | One map check, then a direct load |
| Polymorphic | Two to four in V8 | A short list of map checks |
| Megamorphic | More than four | Falls back to a generic lookup with a global cache |
function getX(p) { return p.x;}
getX({ x: 1 }); // monomorphic: one mapgetX({ x: 1, y: 2 }); // polymorphic: two mapsgetX({ y: 2, x: 1 }); // threegetX({ x: 1, z: 3 }); // fourgetX({ x: 1, w: 4 }); // five: megamorphicThe state matters most for the optimizing compiler. A monomorphic site compiles to a map check and a single load. A megamorphic site cannot be specialized at all. The state belongs to the access site, not to the function’s callers: a utility called from many places with many kinds of objects has megamorphic sites inside it even if each caller is consistent.
Inline caches also cover lookups that reach the prototype chain, such as method calls. Those cached results depend on the prototypes staying the same, so changing an object’s prototype with Object.setPrototypeOf, or adding methods to a shared prototype while the program runs, invalidates them. Set prototypes when objects are created. Prototypes and inheritance covers the chain itself.
Arrays and elements kinds
Integer-indexed properties are stored separately from named ones, in an elements backing store, and V8 tracks what kind of values it holds. The three main elements kinds form a one-way ladder:
PACKED_SMI_ELEMENTS— only small integers.PACKED_DOUBLE_ELEMENTS— numbers, stored as raw doubles.PACKED_ELEMENTS— anything.
Each has a HOLEY_ variant for arrays with missing indices. Holes are expensive, because a read from a hole has to check the prototype chain for an inherited value at that index.
const values = [1, 2, 3]; // PACKED_SMI_ELEMENTSvalues.push(4.5); // PACKED_DOUBLE_ELEMENTSvalues.push('x'); // PACKED_ELEMENTS
const sparse = [1, 2, 3];sparse[10] = 4; // HOLEY_SMI_ELEMENTS
const sized = new Array(3); // HOLEY_SMI_ELEMENTS, even after filling every slot%DebugPrint(values) prints the elements kind after each step. Transitions only go toward more general kinds. An array that once held a string stays PACKED_ELEMENTS after you remove the string, and an array created with new Array(3) stays holey after you fill all three slots. Building the array with push, a literal, or Array.from({ length: n }, fn) keeps it packed.
For large numeric data where the element type is fixed in advance, typed arrays avoid the question entirely; see Typed arrays and ArrayBuffer.
Checking shapes yourself
V8’s internal functions are available in Node with --allow-natives-syntax. They are not JavaScript: a script that uses them is a syntax error without the flag, and their names change between versions. They are useful for confirming what a piece of code does to its objects.
| Function | Shows |
|---|---|
%HaveSameMap(a, b) |
Whether two objects share a map |
%HasFastProperties(obj) |
Whether the object is in dictionary mode |
%DebugPrint(value) |
The map, its properties, and for arrays the elements kind |
node --allow-natives-syntax shapes.jsA CPU profile, from the Chrome DevTools Performance panel or node --cpu-prof, tells you which functions are slow but not why. Once a profile has pointed to a hot function, these helpers and the --trace-deopt flag from the previous chapter show whether its objects share shapes and whether its optimized code keeps being thrown away.
Guidelines that hold across engines
All major engines use shapes and inline caches, so the same habits help everywhere:
- Initialize every property in the constructor or the literal, in the same order. Prefer class fields for classes.
- Keep each property’s type stable. Use one kind of value per field, and pick a placeholder of the same kind (
0,'') rather than switching betweennulland a number in a hot numeric field. - Do not
deleteproperties from objects you keep using. Set them toundefined, or use aMapwhen keys are dynamic. - Keep arrays packed and uniform. Avoid holes, and avoid mixing integers, doubles, and other values in one array.
- Do not change prototypes after creation.
None of this is worth obscuring code for. Most objects are not on a hot path, and the engines are good at the ordinary cases. Apply these habits where a profile shows property access or array iteration as the cost.
Check your understanding
Question 1 of 8
0 of 8 checked