How V8 Gives Every Object a Hidden Class

Every V8 object points at a Map that describes its shape. Adding a property walks a tree of Maps, and the order you add properties in decides which branch you end up on.

TL;DR: Every V8 object points at a Map that describes its shape: instance size, property offsets, the descriptor array. Adding a property does not mutate the object - it walks a tree of Maps, one edge per property name, and the new map is shared with every other object that made the same sequence of additions. The transitions are held in weak references, so an unused shape dies with its last user; when a shape has to change underneath live objects, V8 deprecates the transition tree and deoptimizes the code that depended on it.

JavaScript gives you objects you can mutate at any time. obj.x = 1 on an object that never had an x is legal, and legal again on the next line, and legal again a million times in a loop. The specification says nothing about “shape” - it says properties live in an ordinary ordered list.

V8 needs something much more specific than that. Its optimizing compiler emits code like obj.x assuming that x lives at offset 8 of the object and that reading it is just a load. For that assumption to be sound, V8 needs to know, cheaply and exactly, where each property lives. That knowledge lives in a separate heap object - the Map - and the rules for how maps relate to each other live in a transition tree.

The interesting part is not that the map exists. It is that the tree is weak, that it is ordered, and that changing a shape underneath live objects is a deoptimization event rather than a silent fixup.

The same idea carries the inline cache: in unoptimized code, obj.x compares the receiver’s map against an expected map and, if they match, uses a cached offset. The map is the thing being compared.

The Map is not the prototype

The single most common confusion: people call the map a “prototype” or a “class”. It is neither. The prototype is the object you get when you look up a missing property, and it is reachable from any object. The map is the description of an object - its layout - and it is reachable from every object in the heap.

V8 keeps them in separate fields of the map itself, and the field names make the distinction obvious:

cpp
// src/objects/map.tq:89
  prototype: JSPrototype;
  constructor_or_back_pointer_or_native_context: Object;

  @if(V8_ENABLE_WEBASSEMBLY) instance_descriptors: DescriptorArray
      |WasmStruct;
  @ifnot(V8_ENABLE_WEBASSEMBLY) instance_descriptors: DescriptorArray;

  // Wasm maps with custom descriptors overload the "dependent_code" field to
  // store their direct supertype map (i.e. their type's canonical RTT).
  @if(V8_ENABLE_WEBASSEMBLY) dependent_code: DependentCode|Map;
  @ifnot(V8_ENABLE_WEBASSEMBLY) dependent_code: DependentCode;

  prototype_validity_cell: Zero|Cell;
  transitions_or_prototype_info: Map|Weak<Map>|TransitionArray|PrototypeInfo|
      PrototypeSharedClosureInfo|Zero;

The prototype lives in prototype. The shape lives in instance_descriptors. The last field is the interesting one: depending on what kind of map this is, that single slot holds either the map’s outgoing transitions (a Map, a Weak<Map> or a TransitionArray) or, for a prototype map, a PrototypeInfo. The header comment in map.h spells out the union:

cpp
// src/objects/map.h:250
// +---------------+-------------------------------------------------+
// | TaggedPointer | If Map is a prototype map:                      |
// |               |   [prototype_info]                              |
// |               | Else:                                           |
// |               |   [raw_transitions]                             |
// +---------------+-------------------------------------------------+

A prototype map is a map that describes an object which is used as a prototype. Such an object is read a lot and written rarely, so V8 optimizes its layout differently - and it needs the extra PrototypeInfo slot, so the transitions have to move out of the way.

What a map actually contains

The map itself is small and packed. map.tq defines three bit-field groups plus a handful of bytes:

cpp
// src/objects/map.tq:78
  instance_size_in_words: uint8;
  inobject_properties_start_or_constructor_function_index: uint8;
  used_or_unused_instance_size_in_words: uint8;
  visitor_id: uint8;
  instance_type: InstanceType;
  bit_field: MapBits1;
  bit_field2: MapBits2;
  bit_field3: MapBits3;

bit_field3 is where the interesting bookkeeping lives:

cpp
// src/objects/map.tq:28
bitfield struct MapBits3 extends uint32 {
  enum_length: int32: 10 bit;
  number_of_own_descriptors: int32: 10 bit;
  is_prototype_map: bool: 1 bit;
  is_dictionary_map: bool: 1 bit;
  owns_descriptors: bool: 1 bit;
  is_in_retained_map_list: bool: 1 bit;
  is_deprecated: bool: 1 bit;
  is_unstable: bool: 1 bit;
  is_migration_target: bool: 1 bit;
  is_extensible: bool: 1 bit;
  may_have_interesting_properties: bool: 1 bit;
  construction_counter: int32: 3 bit;
}

is_deprecated is the “this shape is stale, stop using it” flag. is_dictionary_map means the object gave up on fast properties and moved to a name dictionary. construction_counter drives in-object slack tracking - the mechanism that decides how much space to reserve inside the object itself for future properties.

The instance_size_in_words byte plus the inobject_properties_start byte are the reason V8 can do obj.x in one load: the map says “the object’s in-object properties start at word 2, x is the third field, so it’s at address this + 8”.

The descriptor array is where the shape lives

The map does not itself store property names. It points at a DescriptorArray, and the layout of that array is documented right in the header:

cpp
// src/objects/descriptor-array.h:73
// A DescriptorArray is a custom array that holds instance descriptors.
// It has the following layout:
//   Header:
//     [16:0  bits]: number_of_all_descriptors (including slack)
//     [32:16 bits]: number_of_descriptors
//     [64:32 bits]: raw_gc_state (used by GC)
//     [kEnumCacheOffset]: enum cache
//   Elements:
//     [kHeaderSize + 0]: first key (and internalized String)
//     [kHeaderSize + 1]: first descriptor details (see PropertyDetails)
//     [kHeaderSize + 2]: first value for constants / Tagged<Smi>(1) when not
//     used
//   Slack:
//     [kHeaderSize + number of descriptors * 3]: start of slack
// The "value" fields store either values or field types. A field type is either
// FieldType::None(), FieldType::Any() or a weak reference to a Map. All other
// references are strong.

Three words per property: key, details, value. The “details” word packs the property kind, whether it lives in a field or in the dictionary, whether it is const, and the representation of the value:

cpp
// src/objects/property-details.h:97
  enum Kind {
    kNone,
    kSmi,
    kDouble,
    kHeapObject,
    kTagged,
    // This representation is used for WasmObject fields and basically means
    // that the actual field type information must be taken from the Wasm RTT
    // associated with the map.
    kWasmValue,
    kNumRepresentations
  };

kSmi means “this field is known to hold a small integer”, so it can be stored unboxed in the object. That is a layout decision, and it is baked into the map - which means changing a field from kSmi to kDouble is a map change, and possibly a deprecation.

Note that the value slot of a data field holds a FieldType, not a value. FieldType can be Any, None, or a weak reference to another Map whose own field types it inherits. That weak reference is how V8 encodes “this field has the same type as that field over there” without keeping the other map alive.

Adding a property: the transition tree

Here is the core of the whole design. Adding x to an object does not allocate a new object or touch the object’s memory - it looks for (or creates) a child map of the current one:

cpp
// src/objects/map.cc:2096
  MaybeHandle<Map> maybe_transition = TransitionsAccessor::SearchTransition(
      isolate, map, *name, PropertyKind::kData, attributes);
  Handle<Map> transition;
  if (maybe_transition.ToHandle(&transition)) {
    InternalIndex descriptor = transition->LastAdded();

    DCHECK_EQ(attributes, transition->instance_descriptors()
                              ->GetDetails(descriptor)
                              .attributes());

    return UpdateDescriptorForValue(isolate, transition, descriptor, constness,
                                    value);
  }

If a transition for that name already exists, the existing child map is reused. This is the whole point: the map is not per-object, it is per shape history.

If no transition exists, a new child map is built. Map::CopyWithField does the work:

cpp
// src/objects/map.cc:584
MaybeHandle<Map> Map::CopyWithField(Isolate* isolate, DirectHandle<Map> map,
                                    DirectHandle<Name> name,
                                    DirectHandle<FieldType> type,
                                    PropertyAttributes attributes,
                                    PropertyConstness constness,
                                    Representation representation,
                                    TransitionFlag flag) {
  DCHECK(map->instance_descriptors()
             ->Search(*name, map->NumberOfOwnDescriptors())
             .is_not_found());

  // Ensure the descriptor array does not get too big.
  if (map->NumberOfOwnDescriptors() >= kMaxNumberOfDescriptors) {
    return MaybeHandle<Map>();
  }

and the new map is linked into the tree with Map::ConnectTransition, which also handles the “the parent no longer owns its descriptors” bookkeeping:

cpp
// src/objects/map.cc:1643
  if (!IsUndefined(parent->GetBackPointer())) {
    parent->set_owns_descriptors(false);
  } else if (!parent->IsDetached(isolate)) {
    // |parent| is initial map and it must not contain descriptors in the
    // descriptors array that do not belong to the map.
    DCHECK_EQ(parent->NumberOfOwnDescriptors(),
              parent->instance_descriptors()->number_of_descriptors());
  }
  if (parent->IsDetached(isolate) && !force_connect) {
    DCHECK(child->IsDetached(isolate));
    if (v8_flags.log_maps) {
      LOG(isolate, MapEvent("Transition", parent, child, "prototype", name));
    }
  } else {
    TransitionsAccessor::Insert(isolate, parent, name, child, transition_kind);

The tree is navigated via a back pointer stored in the map itself, in the slot that otherwise holds the constructor:

cpp
// src/objects/map.h:785
  // [back pointer]: points back to the parent map from which a transition
  // leads to this map. The field overlaps with the constructor (see above).
  inline Tagged<HeapObject> GetBackPointer() const;

Map::FindRootMap walks it:

cpp
// src/objects/map.cc:773
Tagged<Map> Map::FindRootMap() const {
  DisallowGarbageCollection no_gc;
  Tagged<Map> result = this;
  while (true) {
    Tagged<Map> parent;
    if (!result->TryGetBackPointer(&parent)) {
#if DEBUG
      if (IsJSObjectMap(result)) {
        DCHECK_LE(result->NumberOfOwnDescriptors(),
                  result->instance_descriptors(kAcquireLoad)
                      ->number_of_descriptors());
      }
#endif
      return result;
    }
    result = parent;
  }
}

Why two obj.x = 1 sites make two maps

Now the part that surprises people. Because the tree is keyed on name sequences, the shape depends on the order in which properties were added.

text
                 {}  (initial map)
                  |
              +x  |  +y
                  |
                 {x}
                  |
                +y
                  |
                {x,y}

                 {}  (initial map)
                  |
              +y  |  +x
                  |
                 {y}
                  |
                +x
                  |
                {y,x}

{x} and {y} are different maps, and {x,y} and {y,x} are different maps. Two objects with the same set of properties but different creation orders have different maps, and therefore different inline caches, and therefore different optimized code.

Worse: obj.x = 1 at one site and obj.y = 1 at another site produce two branches off the same root. Every call site that assigns a property to a freshly-created object contributes an edge to this tree. In a large application, the tree can grow wide and deep, and each map is a live heap object.

V8’s answer to that is not to prevent the tree from growing but to let it die. Transitions are stored in weak references:

cpp
// src/objects/transitions.cc:53
  // If the map doesn't have any transitions at all yet, install the new one.
  if (encoding == kUninitialized || encoding == kMigrationTarget) {
    if (flag == SIMPLE_PROPERTY_TRANSITION) {
      ReplaceTransitions(isolate, map, MakeWeak(*target));
      return;
    }
    // If the flag requires a full TransitionArray, allocate one.
    DirectHandle<TransitionArray> result =
        isolate->factory()->NewTransitionArray(1, 0);
    result->Set(0, *name, MakeWeak(*target));
    ReplaceTransitions(isolate, map, result);
    DCHECK_EQ(kFullTransitionArray, GetEncoding(isolate, *result));
    return;
  }

MakeWeak(*target) is the important bit: the child map is reachable only through a weak reference from the parent. If no object in the heap has that shape any more, the child map is collected and the edge disappears. The transition tree is a cache, self-pruning.

There is a fast path worth noting: a map with exactly one outgoing transition stores it inline in the transitions_or_prototype_info slot as a Weak<Map>, with no TransitionArray allocation at all. The encoding of that slot distinguishes the cases.

Deprecation: when a shape has to change underneath live objects

Some changes cannot be done by finding a different map. If a field that was kSmi needs to hold a double, the object has to be rewritten, not just re-pointed. V8 handles this by deprecating the transition tree rooted at the old map:

cpp
// src/objects/map.cc:727
void Map::DeprecateTransitionTreeImpl(Isolate* isolate) {
  if (is_deprecated()) return;
  DisallowGarbageCollection no_gc;
  ReadOnlyRoots roots(isolate);
  TransitionsAccessor transitions(isolate, this);
  transitions.ForEachTransition(
      &no_gc,
      [&](Tagged<Map> map) { map->DeprecateTransitionTreeImpl(isolate); },
      nullptr);
  DCHECK(!IsFunctionTemplateInfo(constructor_or_back_pointer()));
  set_is_deprecated(true);
  if (v8_flags.log_maps) {
    LOG(isolate, MapEvent(no_gc, "Deprecate", this, {}));
  }
  DependentCode::DeoptimizeDependencyGroups<Map>(
      isolate, this, DependentCode::kTransitionGroup);
  NotifyLeafMapLayoutChange(isolate);
}

Two things happen at once. The whole subtree is marked deprecated, so no new objects will adopt those shapes. And DependentCode::DeoptimizeDependencyGroups throws away every piece of optimized code that had baked in the old shape. Objects still holding the old map are migrated lazily, when they are next touched - Map::Update looks for a migration target, and if there isn’t one MapUpdater rewrites the object.

This is why “adding a property changes the shape” is a first-class, observable event in V8, and why Map::TransitionToDataProperty has a special path for “too many properties”:

cpp
// src/objects/map.cc:2114
  if (!map->TooManyFastProperties(store_origin)) {
    Representation representation = Object::OptimalRepresentation(*value);
    DirectHandle<FieldType> type =
        Object::OptimalType(*value, isolate, representation);
    maybe_map = Map::CopyWithField(isolate, map, name, type, attributes,
                                   constness, representation, flag);
  }

and TooManyFastProperties is where the “slow properties” threshold lives:

cpp
// src/objects/map-inl.h:317
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;
}

Past that limit the object is normalized: it stops being a fast-property object and its properties move into a name dictionary. The map becomes a dictionary map and the whole fast-path story is over for that object. This is why obj[computedKey] = v is a cliff and not a slope.

Prototypes use an entirely different mechanism

Because a prototype can be mutated at any time by code you do not control, the “one shape per object” story does not hold for prototypes. V8 handles prototype mutation with validity cells.

A map that serves as a prototype gets a prototype_validity_cell - a Cell holding a token that means “this prototype chain is still as you saw it”. Optimized code that inlined a property lookup through a prototype holds a reference to that cell; when the prototype changes, the cell is invalidated and the dependent code is deoptimized.

cpp
// src/objects/map.cc:2566
  // Ensure the prototype is registered with its own prototypes so its cell
  // will be invalidated when necessary.
  JSObject::LazyRegisterPrototypeUser(validity_cell_holder_map, isolate);

The registration is the interesting part. A prototype needs to know who depends on it, so that it can invalidate them. That is the prototype_users list - a WeakArrayList stored in the PrototypeInfo:

cpp
// src/objects/js-objects.cc:5277
  DirectHandle<Map> current_user = user;
  DirectHandle<PrototypeInfo> current_user_info =
      Map::GetOrCreatePrototypeInfo(user, isolate);
  for (PrototypeIterator iter(isolate, user); !iter.IsAtEnd(); iter.Advance()) {
    // Walk up the prototype chain as far as links haven't been registered yet.
    if (current_user_info->registry_slot() != PrototypeInfo::UNREGISTERED) {
      break;
    }

Note the walk: a prototype registers itself as a user of its prototype, recursively. So the user list of A.prototype contains B.prototype if B.prototype.__proto__ === A.prototype, and the invalidation can walk downwards:

cpp
// src/objects/js-objects.cc:5423
void InvalidatePrototypeChainsInternal(Tagged<Map> map) {
  // We handle linear prototype chains by looping, and multiple children
  // by recursion, in order to reduce the likelihood of running into stack
  // overflows. So, conceptually, the outer loop iterates the depth of the
  // prototype tree, and the inner loop iterates the breadth of a node.
  Tagged<Map> next_map;
  for (; !map.is_null(); map = next_map, next_map = {}) {
    InvalidateOnePrototypeValidityCellInternal(map);

    Tagged<PrototypeInfo> proto_info;
    if (!map->TryGetPrototypeInfo(&proto_info)) return;
    if (!IsWeakArrayList(proto_info->prototype_users())) {
      return;
    }

This is the mechanism behind Object.prototype.__proto__ changes, Reflect.set on a prototype, and class field initializers that shadow a base class accessor.

Keeping maps alive: the retained map list

We said transitions are weak, so shapes die when unused. But some shapes must survive a GC even if no object currently uses them - for instance because optimized code still references the map, or because a debugger is about to ask about it.

V8’s mechanism for that is a per-context list of retained maps, held weakly but with an age counter:

cpp
// src/heap/heap.cc:6526
void Heap::AddRetainedMaps(DirectHandle<NativeContext> context,
                           base::Vector<const IndirectHandle<Map>> maps) {

Each entry stores the map weakly plus a “retain for N more GCs” counter (v8_flags.retain_maps_for_n_gc), and the map sets its own is_in_retained_map_list bit so it is not added twice. When the list overflows, CompactRetainedMaps drops the entries whose weak references have cleared.

Trade-offs

What it buys. Property access becomes a single load at a compile-time constant offset. Inline caches become a pointer comparison against a map. Two objects with the same shape share one map and one set of compiled feedback, so a codebase with thousands of Point objects pays for one shape, not thousands.

What it costs.

  • Memory. Every distinct property-addition sequence is a distinct map, with a descriptor array, a transition array and a back pointer. Programs that build objects in many different orders pay for all of them.
  • Shape fragility. Adding one property to a hot object can deoptimize a whole function, because the object’s map changed and the old map got deprecated.
  • The dictionary cliff. Once an object crosses the fast-property limit it is normalized into a name dictionary, and it never comes back.
  • Weak-reference traffic. Because transitions are weak, every GC has to process and clear them, and a shape that is still in active use has to be re-discovered rather than assumed.
  • Prototype mutation is not free either. Every prototype gets a validity cell and a user list, and mutating a prototype invalidates the cells of everything below it.

The bottom line

A V8 map is a description of an object, not a prototype and not a class. It answers “how big is this object, where does property x live, and what shape are its elements”. Objects do not own their shape; they point at it.

The shape is reached by walking a transition tree whose edges are property names, so the shape of an object is a function of its history, not of its current property set. That tree is stored with weak references, which makes it self-pruning: a shape nobody uses any more costs nothing. When a shape has to change for objects that already exist, V8 deprecates the tree and deoptimizes the code that assumed the old shape.

The mental model to keep: maps are shared, immutable, and cached; objects are mutable and shareable. The interesting engineering is entirely in deciding when to throw a shared shape away. The feedback vector is where the compiler remembers the shapes it has seen.

The thing people get wrong

“Objects with the same properties have the same hidden class.” No. Objects with the same properties added in the same order share a map. const a = {}; a.x = 1; a.y = 2; and const b = {}; b.y = 2; b.x = 1; produce two different maps and two different inline caches, even though a and b are indistinguishable to Object.keys.

The corollary is the rule of thumb: build objects with all their properties in the same order, in one place, and never add properties conditionally. If you have to build an object whose shape varies, build it through a single factory function so the transition tree stays narrow - or accept the dictionary.

And if you want to see this for yourself, node --allow-natives-syntax plus %DebugPrint(obj) prints the map, its properties, and its prototype, and %HasFastProperties(obj) tells you whether the object is still on the fast path or has already been normalized into a dictionary.

V8InternalsObjectsPerformance