Hidden Classes And Inline Caches
A JavaScript object is a dictionary by definition and a struct by implementation, for as long as you let it be. This part is maps and the transition tree, in-object and out-of-object properties and field representations, elements kinds and the lattice arrays slide down, what monomorphic, polymorphic and megamorphic inline caches compile to, prototype method calls and the cells that guard them, the rules derived from the mechanism, and the tools that show shapes.
Why objects need a shape
"Objects in JavaScript are hash maps. How does o.x get compiled to a single load?"
By making objects not be hash maps most of the time. V8 attaches to every object a map (the hidden class): a description of which named properties the object has, in what order, at what memory offsets, with what attributes. Objects built the same way share a map. An inline cache that has seen one object with map M knows the offset of x for every object with map M, so the load is a map comparison and a fixed-offset read. The dictionary is kept in reserve for objects that stop behaving like structs.
- Instance type and size: is this a plain object, an array, a function, a string wrapper; how many bytes to allocate; how many in-object property slots exist.
- Descriptor array: the named properties in insertion order, each with its attributes (writable, enumerable, configurable), its kind (data field, accessor, constant), its location (in-object slot N, or properties-array index N), and its field representation (Smi, Double, HeapObject, Tagged).
- Elements kind: how the indexed properties are stored (next chapter but one).
- Prototype: the object's prototype is part of the map. Two objects with the same properties but different prototypes have different maps.
- Transition array: which property additions lead to which child maps. The tree is navigated from here.
- Back pointer to the parent map; a validity cell for prototype chain checks; a stable bit, set when no transitions have been taken from this map, which lets the optimiser assume objects with it will keep it.
- Objects built by the same sequence of property additions share a map. The sequence is a path in the transition tree; the map is the node at its end.
- The same property set in a different order is a different path, hence a different map.
{x, y}and{y, x}do not share. - Property deletion leaves the tree. There is no "remove x" transition. The object goes to dictionary mode.
- A map is immutable once created (except for the back pointer, transitions, and generalisation of field representations). Objects move between maps; maps do not change meaning under objects.
node --allow-natives-syntax -e "const a={x:1,y:2}, b={x:3,y:4}, c={y:1,x:2}; console.log(%HaveSameMap(a,b), %HaveSameMap(a,c))" prints true false. The entire part is the second answer.Transitions: the tree, slack tracking, and dictionary mode
The map tree is built lazily as programs add properties, shared across every object that walks the same path, and abandoned by objects that do something the tree cannot describe.
- Adding a property looks up a transition in the current map; if found, the object moves to that child map; if not, a new map is created and the transition recorded. The property value is stored in the slot the new map assigns.
- Transition kinds: add a property (by name and attributes), change elements kind, make a property an accessor, freeze or seal (which are transitions to maps with all-non-writable descriptors), and a few others. Changing attributes of an existing property (
Object.definePropertyon an existing field) is a reconfiguration transition. - Object literals bypass the walk: the parser precomputes the final map for
{x: 1, y: 2}("boilerplate"), andCreateObjectLiteralallocates with that map directly. Constructors that assignthis.x = ...; this.y = ...walk the transitions, but the transitions exist after the first instance, so later instances find them in a few pointer chases. - The tree is per prototype root and per context. Objects created in different realms or with different prototypes are in different trees even with identical properties.
- In-object slots are decided at allocation from the map's instance size. For a constructor, V8 initially over-allocates (a generous estimate from the constructor's size), then after a few instances measures how many properties were actually added and shrinks the instance size for later allocations. Early instances keep their slack; it is never harmful, only wasteful.
- Properties added after construction (outside the constructor, later in the object's life) land in the properties array once the in-object slots are used. Still fast, one more dereference, and a transition tree branch that other instances may not take, splitting the shapes.
- Triggers:
deleteof a non-last property; adding many properties (the limit is in the hundreds to low thousands, representation-dependent); using an object as a hash with computed keys; someObject.definePropertypatterns; objects that are prototypes with many properties (which is actually a fast-path case for prototypes, with its own rules). - What it means: the map no longer records property locations; the properties store is a hash table (NameDictionary) keyed by name. Every access is a hash and probe. The object is its own unique shape.
- Getting out: V8 can migrate a dictionary object back to fast mode in some cases (when it becomes a prototype, or after enough stable accesses via
%ToFastPropertiesinternally), but do not rely on it. - The rules: initialise all properties in the constructor; never delete (set to undefined or null); use
Mapfor string-keyed collections with dynamic keys; keep "config object" shapes consistent across call sites.
// same shape: one map, monomorphic everywhere they flow
class Point { constructor(x, y) { this.x = x; this.y = y } }
const a = new Point(1, 2), b = new Point(3, 4)
%HaveSameMap(a, b) // true
// different shape: optional property added conditionally
function make(x, y, z) { const p = { x, y }; if (z !== undefined) p.z = z; return p }
%HaveSameMap(make(1, 2), make(1, 2, 3)) // false: {x,y} vs {x,y,z}
// fix: always define every property, in the same order
function make(x, y, z) { return { x, y, z: z ?? null } }
%HaveSameMap(make(1, 2), make(1, 2, 3)) // true
// same shape, different representation: an int field that becomes a double
const c = { v: 1 }, d = { v: 1.5 }
%HaveSameMap(c, d) // false: field v has representation Smi in c's map, Double in d's
// (the engine generalises c's map on the first double store; a deopt for code that trusted Smi)Object layout: in-object, properties array, elements
A JSObject's memory is a short header followed by in-object property slots, with two side arrays for overflow: named properties beyond the in-object capacity, and indexed properties. Each has a fast representation and a slow one, and the map records which.
- map (4 bytes): the hidden class.
- properties (4 bytes): empty fixed array, a PropertyArray, or a NameDictionary.
- elements (4 bytes): empty fixed array, a FixedArray or FixedDoubleArray, a NumberDictionary (sparse), or a typed array's backing store.
- in-object slots (4 bytes each): as many as the map's instance size allows. A plain
{x, y}is 12 + 8 = 20 bytes, rounded to 24.
- Each descriptor records a representation: Smi (the slot holds a tagged small integer), Double (the slot holds, or points to, an unboxed 64-bit float), HeapObject (a pointer), Tagged (anything). The optimiser uses it: a Smi field is loaded as an int with no check.
- Generalisation: storing a double into a Smi field, or an object into a Double field, generalises the field's representation in the map (and all maps sharing the descriptor), and deoptimises code that depended on the narrower one. This is the "a field that was an int became a double" deopt from part 0. It happens once per field per generalisation; afterwards the wider representation is stable.
- Double fields: V8 stores unboxed doubles in-object on most platforms, so a
{x: 1.5, y: 2.5}point has no heap numbers hanging off it. Mixed int and double use in the same field forces generalisation to Double for ints too (they are stored as doubles), which is fine, or to Tagged (worst case) if an object ever lands there.
- Constructors or literals that set every field, in one order, with consistent types per field (always a number, or always an object, never sometimes null unless always-nullable).
- Initialise numeric fields with the right kind:
this.total = 0then storing1.5later generalises Smi → Double once; harmless.this.ref = nullthen an object: HeapObject; fine.this.v = 0then an object: Tagged; avoid. - Do not add properties after the fact on hot objects; if a property is optional, define it as null or undefined up front.
- Big objects: hundreds of properties push you toward dictionary mode and large descriptor arrays. For records with many fields, consider typed arrays or splitting.
%DebugPrint({x: 1, y: 2.5}): the "in-object properties" section shows x: 1 (const data field 0) and y: 2.5 (const data field 1) with representation annotations in newer builds, and the instance size. "const" there means the engine has not seen the field change since creation: another thing the optimiser exploits.Arrays and elements kinds
An array is an object whose elements store is the main event. The elements store has a kind, tracked in the map, that says how values are represented (small integers, doubles, anything) and whether there are holes. The kind only generalises, never specialises, and optimised code is compiled for the kind it saw.
- PACKED_SMI_ELEMENTS: all small integers, no holes. Loads are an index and a tag check. The target for integer arrays.
- PACKED_DOUBLE_ELEMENTS: all numbers, stored unboxed as raw doubles. No pointers; no heap numbers; a load is a float load. The target for numeric arrays.
- PACKED_ELEMENTS: tagged values of any type. Loads check what they got. The target for arrays of objects or strings.
- HOLEY_* variants: the same three with holes allowed. Every load must check for the hole and, on finding one, consult the prototype chain (because
Array.prototype[i]could legally exist). A permanent extra check per access, plus a loss of some optimisations (the "no holes" guarantee lets bounds-checked loads skip the prototype walk). - DICTIONARY_ELEMENTS: a hash table, for very sparse arrays (
a[1e6] = 1on a small array) or after enough holes. Each access is a hash lookup. - Typed array kinds: UINT8_ELEMENTS and friends, one per typed array type. Fixed representation, no holes, no transitions.
- Storing a wider type: a double into a Smi array → DOUBLE; an object into a Double or Smi array → ELEMENTS. The backing store is reallocated and converted.
- Creating a hole: writing past the end (
a[a.length + 1] = v),new Array(n),a.length = bigger,delete a[i]. Packed → Holey. Never back. - Going sparse: an index far beyond the length, or too many holes relative to size → DICTIONARY.
- Create with literals or push; avoid
new Array(n)followed by element assignment. If you need a pre-sized array,Array.from({length: n}, () => 0)ornew Array(n).fill(0)(fill converts to packed in current V8 for the common cases) or a typed array. - Keep element types consistent. An array of numbers should not get a null in it; use NaN or a sentinel number, or accept PACKED_ELEMENTS from the start.
- Never delete from arrays; splice, or filter into a new one.
- Do not read past the end in hot code:
a[i]withi >= lengthreturns undefined via the prototype walk, and teaches the IC that out-of-bounds happens, which costs optimisations. - Array-likes and arguments are not arrays; convert once (
Array.from, spread) at the boundary. - Builtins check kinds:
Array.prototype.mapand friends have fast paths for packed Smi and Double arrays with an unmodified prototype chain. Holey or dictionary arrays hit the generic path.
node --allow-natives-syntax -e "const a=[1,2,3]; %DebugPrint(a); a[5]=6; %DebugPrint(a)". The elements kind in the second print is HOLEY_SMI_ELEMENTS. Then try new Array(3) and [,1] and Array(3).fill(0) and see which start holey.Inline caches: mono, poly, mega, in machine code
An inline cache is a feedback slot plus the code that consults it. In the interpreter the IC is a handler that checks the slot; in optimised code the IC's state at compile time is baked into the generated instructions. The three states produce three very different pieces of code for the same source.
- Monomorphic: map check, then a load from a constant offset. If the map is also "stable" (no transitions have been taken from it) the optimiser may even skip re-checking it within a function where it was already checked.
- Polymorphic: a chain of map checks, each with its own offset. Up to four. The order is the order the maps were observed, so the most common should be first; the engine does not reorder.
- Megamorphic: a call to the generic load builtin, which probes the stub cache (a fixed-size hash table of (map, name) → handler) and falls back to the runtime. Nothing inlined; nothing hoisted; nothing proven.
- Existing property, mono: map check, store to offset, done. With representation checks: storing a double into a Smi field triggers generalisation at runtime, which is a slow path and a deopt for others.
- New property, mono: map check, store to the new slot, update the map pointer to the transition target. The transition is a compile-time constant in optimised code.
- Megamorphic stores go through the generic store builtin, which must find or create transitions on every call.
o.method()is a load ofmethod(an IC, found on the prototype) followed by a call (another IC, on the target). The load IC records "on prototype P at depth 1, constant function F"; a prototype validity cell guards that nothing on the chain changed. So a monomorphic method call is a map check, a cell check, and a direct (often inlined) call.- Modifying a prototype after the fact (
Point.prototype.x = ..., or a library that patchesArray.prototypeat load) invalidates the cells for every object chain through it; every optimised function that trusted them deopts once. Monkey-patch at startup, before anything is hot, or not at all. - Polymorphic call targets (a callback that is one of three functions) can still be inlined with a target check each. Megamorphic call sites (event emitters with hundreds of listeners) are indirect calls; fine, but nothing more.
// a method call: o.greet() // 1. load o's map, check against feedback (the receiver's shape) // 2. the property "greet" is NOT on o: the map says so (fast: the map knows its own properties) // 3. the feedback also recorded: found on prototype P at depth 1, as a constant function // 4. a "prototype chain validity cell" guards that no object between o and P changed // 5. so: one map check + one cell check → the function pointer is a compile-time constant → inlinable // what breaks it: Point.prototype.greet = otherFn // invalidates the cell; every optimised caller deopts once o.greet = ownFn // o transitions to a map WITH greet; callers go polymorphic Object.setPrototypeOf(o, other) // o gets a new map; prototype chain reshuffled; slow for a while
The rules, derived
Every rule of thumb about fast objects is a statement about maps, elements kinds or IC states. Here they are with the mechanism beside each, so you can tell which ones apply to your code and which are cargo.
| Rule | Mechanism | How much it matters |
|---|---|---|
| Initialise all properties in the constructor, same order | One transition path → one map for all instances → monomorphic ICs | Large, in any hot path that touches the objects |
| Never delete a property | No transition exists; the object goes to dictionary mode | Large for that object; small if it is cold |
| Use classes or factory functions, not ad hoc literals with varying keys | Consistent construction → shared maps | Large for data that flows through hot code |
| Keep a field's type consistent | Field representation generalisation (Smi → Double → Tagged) causes deopts and loses unboxed storage | Medium; one-time deopt, then permanently wider |
| Prefer arrays of the same element type; no holes | Elements kind lattice; holey loads check the prototype chain | Large in numeric loops |
Do not use new Array(n) then assign | Starts HOLEY and never repacks | Medium |
Use Map for dynamic string keys | Objects with many computed keys go to dictionary mode; Map is a hash table by design with a fast path | Large for caches and lookups |
| Do not modify built-in prototypes at runtime | Prototype validity cells invalidate; every dependent optimised function deopts | Large and global |
| Do not change an object's prototype after creation | New map, new tree, ICs reset | Large for that object's users |
| Avoid polymorphism in hot helpers | Feedback is per function; 5+ shapes → megamorphic forever | Large; the most common silent regression |
| Hot call sites, few targets | Call IC mono/poly → inlining | Medium |
| Access properties the same way | o.x and o['x'] are both named loads; o[k] with variable k is keyed: a different IC with weaker feedback | Small to medium |
Object spread and Object.assign into a new literal produce consistent maps | CloneObject IC caches the source map → target map transition | Fine; cheap in current V8 |
| Freeze config objects at startup if you like; it costs a transition once | Frozen maps are stable and never transition again | Neutral for speed; good for correctness |
Reading maps and ICs: tools
| Tool | Shows | Use it for |
|---|---|---|
%DebugPrint(obj) (with --allow-natives-syntax) | The map (address, instance size, in-object count, elements kind, stable bit, back pointer), descriptors with locations and representations, the properties store, elements, prototype | What shape is this object; is it in dictionary mode; which elements kind |
%HaveSameMap(a, b) | Boolean | Do two objects share a shape; the one-line shape test |
%HasFastProperties(obj), %HasSmiElements(arr), %HasDoubleElements, %HasHoleyElements, %HasDictionaryElements | Booleans | Asserting a representation in a test or a benchmark harness |
--trace-ic (d8) / --log-ic + --logfile (node) | IC transitions: LoadIC/StoreIC/KeyedLoadIC with state changes, source position, property name, maps | Finding polymorphic and megamorphic sites by file and line |
--trace-maps + --logfile, then tools/map-processor in the V8 repo | Every map creation and transition with a stack trace | Where unexpected shapes are born |
--trace-elements-transitions | Each elements kind transition with from and to | Which store made the array holey or generic |
--trace-deopt | "wrong map", "wrong elements kind", "field type generalisation" reasons with the function and bytecode offset | Confirming a shape problem is what deoptimised a hot function |
| Chrome DevTools Memory → heap snapshot → an object → "map" in the retainers and the "(system) Map" entries | How many maps exist; which objects share which | Shape explosion: thousands of maps for one logical type means inconsistent construction |
V8's system-analyzer (tools/system-analyzer in the repo) on a --log-all log | A UI over maps, ICs, deopts and code events over time | The full picture for a hard case |
node --log-ic --logfile=ic.log app.js, run the workload, then grep "(P->N)" ic.log and grep "(1->P)" ic.log. The megamorphic sites in that function are the list; each one is an object constructed inconsistently somewhere upstream.