# 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.

- Published: 2026-09-13
- Tags: javascript, performance, v8
- Source: https://jsledger.com/blog/object-shapes-are-a-tree/
- Language: en-US
- Author: Jonah Vail

---
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](/blog/how-v8-decides-to-optimize-your-function/), 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](/blog/what-await-compiles-to/).

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.
