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.

One transition chain versus a branching map graph On the left, every object follows the same three transitions from the empty shape, producing four maps in total no matter how many objects are created. On the right, conditional assignment of two optional fields produces a branching graph of seven maps, and no two branches can share an inline cache. Same data, two construction patterns — four maps or seven Fixed key set and order { } {id} {id,name} {id,name,email} 4 maps · every access site monomorphic Conditional assignment { } {id} {id,name} {id,email} {id,name,email} {id,email,name} 7 maps · access sites polymorphic The two right-hand shapes hold identical data in different orders — and V8 treats them as unrelated layouts.

Step-by-Step Fix

  1. 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.
  2. 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.
  3. Normalise construction. Give every object the full key set in a fixed order, with null for absent values. Verification: re-run --trace-maps; the create count drops to roughly the number of real record types.
  4. Remove delete from hot objects. Replace with an undefined assignment, or rebuild the object. Verification: the constructor no longer appears with unusually large shallow sizes in a snapshot.
  5. 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;
}
Before and after normalising the factory Across two hundred thousand records the map count falls from four thousand one hundred to twelve, and bytes per instance falls from one hundred and forty-eight to eighty-eight. Total heap for the population drops from twenty-nine point six megabytes to seventeen point six. 200 000 user records, one factory change distinct maps 4 100 12 after bytes per instance 148 B 88 B after population total 29.6 MB 17.6 MB after

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.

Map space over an hour of steady traffic The normalised build's map space rises during the first two minutes as shapes are created, then stays flat at about one megabyte for the rest of the hour. The build with conditional assignment climbs continuously to nine megabytes and shows no sign of levelling off. A flat map space is the signature of a healthy construction path 0 5 MB 10 MB elapsed (0 → 60 min) normalised: flat at ~1 MB conditional keys: 9 MB and climbing Neither line is a leak in the usual sense — the red one is the engine describing shapes your code keeps inventing.

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.