How Hidden Classes and Inline Caches Affect Memory
Shape churn is the memory problem that does not look like a memory problem: nothing leaks, no structure grows without bound, and yet map space climbs and per-instance costs are higher than they should be. This page belongs to object, string and collection memory costs within JavaScript Memory Fundamentals & Runtime Mechanics, and covers spotting it and removing it.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
map_space grows steadily on a stable workload |
A factory producing many distinct shapes | Normalise the key set and order at the construction site |
| Two snapshots show the same object count but more bytes | Objects transitioned into wider or dictionary-mode layouts | Look for delete and conditional property assignment |
| Property access on a hot path is unexpectedly slow | Polymorphic inline caches from mixed shapes | Make the population monomorphic, then re-measure |
| A million records cost far more than their fields | Spilled properties or dictionary mode | Fix the shape, then re-check bytes per instance |
| Adding one optional field doubled per-instance cost | Field pushed the object past the in-object slot limit | Reduce the field set or split rarely-read fields out |
Root Cause: One Map per Shape, Shared by Every Instance
V8 describes an object’s layout with a map — a hidden class listing the property names, their order and where each value lives. The map is shared, which is the whole point: a million objects created identically share one map, and the per-object cost of layout information is a single pointer.
Transitions are how the map graph is built. Start from {}, add id, and V8 either follows an existing transition to a map describing {id} or creates one. Add name next and you get a map for {id, name}. The chain is cached, so the second million objects built the same way follow the same transitions with no new maps at all. The problem is combinatorial: build objects that sometimes have name, sometimes email, sometimes both, sometimes in the other order, and each distinct set-and-order combination gets its own map.
Two things then get worse together. Map space grows — modestly, at a couple of hundred bytes per shape. And every property access site that sees more than four shapes gives up on its inline cache and falls back to a generic lookup, which is a speed cost that also prevents some of the layout optimisations that keep objects compact. Deleting a property can push an object further, into dictionary mode, where the shared map is replaced by a per-object hash table with roughly double the per-property cost.
Step-by-Step Fix
- Confirm the symptom in map space. Command:
node -e "const v8=require('v8'); console.log(v8.getHeapSpaceStatistics().find(s=>s.space_name==='map_space'))"inside your process, sampled twice minutes apart. Verification: a steady climb on a workload of constant shape means churn. - Find the factory. Command:
node --trace-maps app.js 2>&1 | grep -c 'map-create'on a representative workload. Verification: the count is far larger than the number of distinct record types in the domain. - Normalise construction. Give every object the full key set in a fixed order, with
nullfor absent values. Verification: re-run--trace-maps; the create count drops to roughly the number of real record types. - Remove
deletefrom hot objects. Replace with anundefinedassignment, or rebuild the object. Verification: the constructor no longer appears with unusually large shallow sizes in a snapshot. - Re-measure bytes per instance. Verification: the per-instance figure falls, usually by 20–40% when dictionary-mode objects return to fast mode.
Command & Code Reference
The normalised factory. The ?? fallbacks are what keep every record on one transition chain.
// One shape for every row, whatever the input contains.
const EMPTY = { id: 0, name: null, email: null, role: null, lastSeen: null };
function makeUser(row) {
// Object spread of a constant template guarantees key order and key set,
// so V8 follows one transition chain for the whole population.
return {
...EMPTY,
id: row.id,
name: row.name ?? null,
email: row.email ?? null,
role: row.role ?? null,
lastSeen: row.lastSeen ?? null,
};
}
Counting shapes directly, which is more reliable than reasoning about the code.
// Two objects share a shape if V8 gives them the same map. There is no public
// API for that, but %HaveSameMap is available under --allow-natives-syntax
// and is the fastest way to check an assumption during development.
// node --allow-natives-syntax shapes.js
const a = makeUser({ id: 1, name: 'Ada' });
const b = makeUser({ id: 2 }); // no name in the input
console.log(%HaveSameMap(a, b)); // must print true
const bad = { id: 3 };
if (true) bad.name = 'Grace'; // conditional → different shape
console.log(%HaveSameMap(a, bad)); // prints false
Keeping objects in fast mode when a field has to go away.
function clearOptional(user) {
// delete demotes hot objects to dictionary mode: the shared map is replaced
// by a per-object hash table costing roughly twice as much per property.
// delete user.email; ← avoid on large populations
// Assignment keeps the layout and the shared map intact.
user.email = null;
return user;
}
Verification & Regression Prevention
The check that sticks is a unit test rather than a measurement. Assert that two objects produced by the factory from different inputs have identical key sequences: expect(Object.keys(makeUser(rowA))).toEqual(Object.keys(makeUser(rowB))). That catches the reintroduction of a conditional assignment at review time, costs microseconds, and never flakes.
For the runtime side, export map_space size as a gauge alongside the heap gauges described in production memory monitoring. A map count that climbs on a stable workload is a specific, actionable signal — unlike total heap growth, it has essentially one cause.
FAQ
Does adding a property later really create a new object shape?
Yes. V8 records a transition from the old map to a new one describing the extra property, and the object now points at the new map. Transitions are cached and shared, so a single consistent path is cheap; the cost appears when many different property sets and orders are produced, because each combination gets its own map object in map space.
How much memory does shape churn actually waste?
A map is on the order of 80–200 bytes depending on the number of descriptors, so a thousand extra shapes is only a couple of hundred kilobytes directly. The larger cost is indirect: objects in many shapes cannot share inline caches, polymorphic access sites deoptimise, and objects that fall into dictionary mode roughly double their per-property cost.
Is delete always bad?
Not always — on a small, cold object it is fine and clearer than the alternative. On a hot object with many instances it can demote the object to dictionary mode, replacing the shared map with a per-object hash table. If the property must genuinely disappear from iteration, prefer rebuilding the object over deleting from a million instances.
Related
- Object Shapes, Strings and Collection Memory Costs — the surrounding cost model
- Map vs Object vs Array Memory Overhead — choosing the container once the shape is fixed
- Understanding the V8 Heap Layout and Memory Segments — where map space sits among the other heap spaces