Object Shapes Are a Tree, and the Tree Is Shared

A hidden class is a node in a transition tree V8 keeps for the whole isolate — which is why an unrelated function can decide how your object is stored.

A radial steel fixture clamped on a bench: identical arms branching outward from one central stack of discs on a shaft, with a blank mustard tag hanging on a wire beside the hub.

The same function, run in two processes, hands back objects with two different internal representations:

js
// node --allow-natives-syntax shared.mjs [named-first]
function keyed() {
  const o = {};
  for (let i = 0; i < 30; i++) o['p' + i] = i;
  return o;
}
// process A: keyed() only            -> %HasFastProperties(k) === false
// process B: run a function that sets p0…p29 by name first,
//            then keyed()            -> %HasFastProperties(k) === true

Nothing about keyed changed between the runs, and the first function’s object is never used again — it is not even assigned to anything. What changed is that V8’s transition tree already had a path for those thirty names, and following an existing path is not the same operation as creating one.

Printing the tree instead of drawing it

Every explanation of hidden classes draws the tree. It is more useful to print it. Under --allow-natives-syntax, %DebugPrint gives the map’s address and its back pointer, so the edges are readable:

$ node --allow-natives-syntax tree.mjs        # Node 24.20.0, V8 13.6.233.17-node.53
const o = {};   - map: 0x297c5c00c9a9   - back pointer: <undefined>
o.x = 1;        - map: 0x06fa2a229081   - back pointer: 0x297c5c00c9a9
o.y = 2;        - map: 0x06fa2a2290c9   - back pointer: 0x06fa2a229081

The addresses change on every run; the relationships do not. Each store produces a new map whose back pointer is the previous one. That is a chain, and a chain would be enough if maps belonged to objects. They do not:

js
const a = { x: 1 };
const b = { x: 1 };
%HaveSameMap(a, b);   // true  — one node, two objects
a.y = 2;
b.z = 3;
%HaveSameMap(a, b);   // false — two children

Printed, a and b now have different maps with the same back pointer address. One parent, two children, which is what makes it a tree: V8’s own documentation describes the edges as a TransitionArray, “an array of ‘edges’ from this Map to sibling Maps,” each edge keyed by a property name.

The tree outlives the objects. It is keyed by names and attributes, not by who built them, which is the part the opening example turns on.

Why the same code produced two answers

V8 has a heuristic for objects that keep growing, and Map::TooManyFastProperties is where it lives. Reading it in the V8 source explains both runs:

cpp
bool Map::TooManyFastProperties(StoreOrigin store_origin) const {
  if (UnusedPropertyFields() != 0) return false;
  if (store_origin != StoreOrigin::kMaybeKeyed) return false;
  if (is_prototype_map()) return false;
  int limit = std::max(
      {v8_flags.fast_properties_soft_limit.value(), GetInObjectProperties()});
  int external =
      NumberOfFields(ConcurrencyMode::kSynchronous) - GetInObjectProperties();
  return external > limit;
}

Two lines carry the behavior. The check applies only to kMaybeKeyed stores — o[key] = value, not o.name = value — and the soft limit defaults to 12 (fast_properties_soft_limit in flag-definitions.h). Measured on Node 24.20.0, a loop of keyed stores into a fresh object leaves fast properties at the twentieth, not the thirteenth, because the check is skipped while the object still has unused property slots, and those are handed out in batches (JSObject::kFieldsAdded is 3).

The rescue in the opening example comes one level up, in Map::TransitionToDataProperty:

cpp
  MaybeHandle<Map> maybe_transition = TransitionsAccessor::SearchTransition(
      isolate, map, *name, PropertyKind::kData, attributes);
  Handle<Map> transition;
  if (maybe_transition.ToHandle(&transition)) {
    // …
    return UpdateDescriptorForValue(isolate, transition, descriptor, constness,
                                    value);
  }
  // …
  if (!map->TooManyFastProperties(store_origin)) {

The heuristic is consulted only when no transition exists. If the path is already in the tree, V8 follows it and returns before the check runs. That is why one earlier function, whose object nothing kept, is enough to change the representation of every later object built from the same names. Both listings are from V8’s main branch, retrieved 2026-08-31; Node 24.20.0 ships V8 13.6, so treat them as the current shape of the logic rather than a byte-for-byte match.

The rescue is also not durable, which is the strongest evidence that the tree is a cache and not a record. Add --expose-gc and call gc() twice between the two functions, and keyed() returns a dictionary object again: unused branches are collected, and the path has to be paid for a second time.

What it costs when the check does fire

Dictionary mode is a hash table, and the price shows up on reads. This benchmark gives each loop iteration a different object, so the loads cannot be hoisted out:

js
const N = 100000, fastArr = [], slowArr = [];
for (let i = 0; i < N; i++) {
  fastArr.push({ x: i, y: i, z: i });
  const s = { x: i, y: i, z: i, w: i };
  delete s.w;                       // s is now a dictionary object
  slowArr.push(s);
}
// median of 7 runs, 100000 objects x 3 reads, Node 24.20.0:
// fast properties  0.09 ms
// dictionary mode  4.30 ms

Roughly 48 times, for objects that hold the same three values. This is the same mechanism as the inline caches in the launch V8 article, one layer earlier: an inline cache can specialize on a map, and a dictionary object does not offer one to specialize on.

delete, undefined, and the way back

The folklore says delete is expensive and setting undefined is not. Measured, it is sharper than that:

js
const d1 = { x: 1, y: 2 }; d1.y = undefined;  %HasFastProperties(d1);  // true
const d2 = { x: 1, y: 2 }; delete d2.y;       %HasFastProperties(d2);  // false
const d3 = { x: 1, y: 2 }; delete d3.y; d3.y = 2;  // false — re-adding does not undo it

d2 deletes the property that was added last, which is the case people expect to be cheap, and it goes to dictionary mode anyway. There is a way back, and it is a copy rather than a repair: both { ...d2 } and Object.assign({}, d2) return objects that report fast properties.

Same names, same order, different map

The rule everyone repeats is that objects with the same properties in the same order share a hidden class. Two ways of writing the same object disagree:

js
const lit = { x: 1, y: 2 };
const built = {}; built.x = 1; built.y = 2;
%HaveSameMap(lit, built);   // false

%DebugPrint says why. The literal is sized exactly — instance size 40, two in-object properties, nothing unused. The built object started as {}, which V8 sizes for four in-object properties, so it ends at instance size 56 with two slots still empty. Same names, same order, same values, different node in the tree, and a call site that reads both is polymorphic from the first call.

The map you get first is not the one you keep

One more reason a shape is not a label: V8 sizes objects optimistically and then corrects itself. The first instance of a constructor gets in-object slack, which slack tracking removes once V8 has seen enough instances.

$ node --allow-natives-syntax slack.mjs
function Point(x, y) { this.x = x; this.y = y; }
first instance:  instance size 104 · inobject properties 10 · unused fields 8
after 20 more:   instance size  40 · inobject properties  2 · unused fields 0

Slack is also finite. Start from { x: 1 }, which V8 sizes for exactly one in-object property, then add y and z: both land in a separate PropertyArray[3], one indirection away from the object, and %DebugPrint reports their location as properties[0] and properties[1] rather than in-object.

What to inspect, and what I could not

Three commands cover most questions: %HaveSameMap(a, b) for whether two objects share a node, %HasFastProperties(o) for whether the object still has a map worth sharing, and %DebugPrint(o) for the map address, the back pointer and where each property physically lives. All three need --allow-natives-syntax, which is a debugging flag and not something to ship — the same class of flag that prints V8’s bytecode for an async function.

What I could not do is watch the tree being built: Node 24.20.0 rejects --trace-maps outright (node: bad option): map event logging sits behind V8’s build-time V8_TRACE_MAPS macro, and this build does not have it. The back pointers are the substitute, and they are enough to see the shape of the structure but not its history.

The practical version of all this is unchanged from the folklore — build objects the same way, in one place, and do not delete. What changes is why. It is not that V8 dislikes irregular objects. It is that the tree only pays for itself when paths get reused, and every rule above is a way of reusing one.

FAQ

Frequently asked

Does property order really matter?

Yes, and it is checkable rather than a matter of belief. On Node 24.20.0, %HaveSameMap returns true for two objects built with x then y, and false when one of them was built with y then x. Different order means a different path through the transition tree, which means a different map.

Is it safe to use delete if I only remove the last property I added?

Measured on Node 24.20.0, no. An object built as {x, y} with delete of y reports %HasFastProperties false, the same as deleting a property from the middle. Setting the property to undefined keeps fast properties; re-adding a deleted property does not restore them.

Arrow keys to move, Enter to open.