Runtime and performanceThe V8 object modelPart 2
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.

The same function, run in two processes, hands back objects with two different internal representations:
// 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) === trueNothing 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: 0x06fa2a229081The 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:
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 childrenPrinted, 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:
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:
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:
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 msRoughly 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:
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 itd2 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:
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 0Slack 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.


