The Prototype Chain: How V8 Builds an Object's Class Hierarchy

A class in V8 is two objects and a Map: the constructor, its prototype object, and the Map that every instance shares. This is how initial maps get cached, how super() and new.target work, and how a prototype change deoptimizes your code.

TL;DR: A class in V8 is two heap objects - a JSFunction (the constructor) and a JSObject (the prototype) - plus a Map that every instance shares. The constructor holds that Map in a single overloaded field, prototype_or_initial_map, which can be a hole, a prototype, a Map, or a Tuple2. Derived Maps (for Object.create, Reflect.construct, subclasses) are cached as weak references in the prototype’s PrototypeInfo.derived_maps list. Prototype mutation is made safe by a per-prototype validity cell plus a weak list of prototype users; invalidating the cell deoptimizes the code that embedded it.

The specification describes a prototype chain as a linked list of ordinary objects. Every object has a [[Prototype]] internal slot, and property lookup walks the list until it finds the property or reaches null.

That model is a terrible fit for a JIT compiler. A property load in optimized code is supposed to be one compare and one load. But if the prototype chain is a list of arbitrary objects, then “where does x live?” cannot be answered without walking the chain - and the chain can change under your feet at any moment.

V8’s answer is to treat the prototype chain as part of the object’s shape. An instance’s Map names its prototype; the prototype’s Map names its prototype; and so on up to Object.prototype. Change the prototype and you change the shape of everything downstream, which is exactly the event the compiler needs to hear about.

A class is two objects and a Map

When you write

javascript
class Point {
  constructor(x, y) { this.x = x; this.y = y; }
  get length() { return Math.hypot(this.x, this.y); }
}

V8 does not create one “class object”. It creates:

  1. a JSFunction - the constructor, callable only via new,
  2. a JSObject - the prototype, which holds constructor and the methods,
  3. a Map - the initial map, describing the shape of every Point instance.

The prototype object is created with an empty map, deliberately, so that constant tracking does not have to deal with in-object properties:

cpp
// src/runtime/runtime-classes.cc:480
DirectHandle<JSObject> CreateClassPrototype(Isolate* isolate) {
  // For constant tracking we want to avoid the hassle of handling
  // in-object properties, so create a map with no in-object
  // properties.

  // TODO(ishell) Support caching of zero in-object properties map
  // by ObjectLiteralMapFromCache().
  DirectHandle<Map> map = Map::Create(isolate, 0);
  return isolate->factory()->NewJSObjectFromMap(map);

Then the prototype’s map is turned into a prototype map and wired to the constructor:

cpp
// src/runtime/runtime-classes.cc:497
  DirectHandle<Map> map(prototype->map(), isolate);
  map = Map::CopyDropDescriptors(isolate, map);
  map->set_is_prototype_map(true);
  Map::SetPrototype(isolate, map, prototype_parent);
  isolate->UpdateProtectorsOnSetPrototype(prototype, prototype_parent);
  constructor->set_prototype_or_initial_map(*prototype, kReleaseStore);

Two things happen here that matter later. set_is_prototype_map(true) marks the map as belonging to something that will be looked through rather than looked at, which is what lets V8 treat prototypes differently from ordinary objects. And set_prototype_or_initial_map stores the prototype object in a field that will later hold the initial map instead.

The four-state field

That field is the cleverest bit of the whole design, because a constructor needs to answer two different questions with one word:

  • “what is my .prototype?” (an object), and
  • “what shape do my instances have?” (a Map).

V8 packs both into prototype_or_initial_map, whose type is a union:

torque
// src/objects/js-function.tq:63
extern class JSFunctionWithPrototype extends JSFunction {
  // The field can be in the following states:
  //  - TheHole - function's prototype was never accessed and thus it's not
  //      initialized yet,
  //  - JSReceiver - function has an instance prototype but initial map was
  //      not created yet (in this case the value is the prototype),
  //  - Map - this is the function's initial map and the function has an
  //      instance prototype,
  //  - Tuple2<Map|JSReceiver, JSAny && !JSReceiver> - function has a
  //      non-instance prototype, value1 contains initial map or instance
  //      prototype value, value2 contains the non-instance prototype value.
  prototype_or_initial_map: JSReceiver|Map|Tuple2|TheHole;
}

Note the comment on the Tuple2 case: it exists for the pathological case where C.prototype is set to a non-object (e.g. C.prototype = 5). The spec says new C() must then fall back to the realm’s intrinsic default prototype, but C.prototype must still read back as 5. A single field cannot hold both, so V8 stores the “real” prototype-to-use in slot 1 and the user-visible value in slot 2.

The initial map is created lazily. Nothing needs a Map until the first new C():

cpp
// src/objects/js-function.cc:940
void JSFunction::EnsureHasInitialMap(Isolate* isolate,
                                     DirectHandle<JSFunction> function) {
  DCHECK(function->has_prototype_slot());
  DCHECK(IsConstructor(*function) ||
         IsResumableFunction(function->shared()->kind()));
  if (function->has_initial_map()) return;

and the number of in-object properties is predicted from the constructor’s source before the map is built, which is why new Point(1, 2) does not need a slow-path rehash:

cpp
// src/objects/js-function.cc:966
  int instance_size;
  int inobject_properties;
  CalculateInstanceSizeHelper(instance_type, 0, expected_nof_properties,
                              &instance_size, &inobject_properties);

expected_nof_properties comes from counting the constructor’s this.x = ... assignments in the parser. That prediction is what lets V8 allocate the instance and its two fields in one go.

Caching derived maps: the prototype’s PrototypeInfo

Now the interesting question. A Map is not just “the shape of a Point”. There are several different shapes you might need for the same prototype:

  • the map for new Point() - the constructor’s own initial map,
  • the map for Object.create(Point.prototype) - a fresh object with that prototype but none of Point’s constructor behaviour,
  • the map for new Sub() where Sub extends Point - a different prototype, so a different shape,
  • the map for Reflect.construct(Point, args, Other) - yet another prototype.

Recomputing a Map on every Object.create would be silly, so V8 caches them on the prototype. The cache lives in a PrototypeInfo struct, and it is a WeakArrayList:

torque
// src/objects/prototype-info.tq:33
  // [derived_maps]: A field caching derived maps. The first entry is the map
  // for Object.create. The following contain entries used by GetDerivedMap
  // which feeds Reflect.construct, proxies and similar.
  derived_maps: WeakArrayList|Undefined;

Index 0 is special-cased as the Object.create map. The rest are searched linearly, and the match criterion is deliberately coarse - same constructor, same instance type:

cpp
// src/objects/prototype-info-inl.h:162
Tagged<MaybeObject> PrototypeInfo::GetDerivedMap(DirectHandle<Map> from) {
  if (IsUndefined(derived_maps())) {
    return Tagged<MaybeObject>();
  }
  auto derived = Cast<WeakArrayList>(derived_maps());
  const uint32_t derived_length = derived->length().value();
  // Index 0 is the map for object create
  for (uint32_t i = 1; i < derived_length; ++i) {
    Tagged<MaybeObject> el = derived->Get(i);
    Tagged<Map> map_obj;
    if (el.GetHeapObjectIfWeak(&map_obj)) {
      Tagged<Map> to = Cast<Map>(map_obj);
      if (to->GetConstructor() == from->GetConstructor() &&
          to->instance_type() == from->instance_type()) {
        return el;
      }
    }
  }
  return Tagged<MaybeObject>();

Weak references, not strong ones. If the Map for a particular prototype/constructor combination dies, the cache entry simply disappears and V8 rebuilds it on demand. This is the same weak-transition-tree trick used for property transitions, applied to prototypes.

The Torque side has a fast path that avoids the runtime entirely when the constructor already has an initial map whose back-pointer matches:

torque
// src/objects/js-objects.tq:80
macro GetDerivedMap(
    implicit context: Context)(target: JSFunction,
    newTarget: JSReceiver): Map {
  try {
    const constructor =
        Cast<JSFunctionWithPrototype>(newTarget) otherwise SlowPath;
    dcheck(IsConstructor(constructor));
    if (Is<TheHole>(constructor.prototype_or_initial_map)) goto SlowPath;
    const map =
        Cast<Map>(constructor.prototype_or_initial_map) otherwise SlowPath;
    if (LoadConstructorOrBackPointer(map) != target) {
      goto SlowPath;
    }

    return map;
  } label SlowPath {
    return runtime::GetDerivedMap(context, target, newTarget, FalseConstant());

The LoadConstructorOrBackPointer(map) != target check is the whole trick: if newTarget’s initial map was built from target, it is exactly the map we want, and no allocation is needed.

new.target and super()

new.target is not a magic global. It is a variable in the constructor’s scope, and the compiler stores it in a register for the duration of the constructor.

super() is more interesting, because the superclass is not stored anywhere obvious. It turns out the superclass is exactly the prototype of the constructor function. The constructor’s [[Prototype]] is the superclass constructor (that is how Point.__proto__ === Object works), and V8 keeps it in the constructor’s map:

cpp
// src/codegen/code-stub-assembler.cc:18813
TNode<HeapObject> CodeStubAssembler::GetSuperConstructor(
    TNode<JSFunction> active_function) {
  TNode<Map> map = LoadMap(active_function);
  return LoadMapPrototype(map);

One load of the map, one load of the prototype. No scope lookup, no property lookup on Object.getPrototypeOf. The super binding is a map read.

The bytecode generator then uses this to emit the super call, with a ThrowIfNotSuperConstructor check afterwards:

cpp
// src/interpreter/bytecode-generator.cc:7291
void BytecodeGenerator::BuildGetAndCheckSuperConstructor(
    Register this_function, Register new_target, Register constructor,
    BytecodeLabel* super_ctor_call_done) {
  bool omit_super_ctor = v8_flags.omit_default_ctors &&
                         IsDerivedConstructor(info()->literal()->kind());

  if (omit_super_ctor) {
    BuildSuperCallOptimization(this_function, new_target, constructor,
                               super_ctor_call_done);
  } else {
    builder()
        ->LoadAccumulatorWithRegister(this_function)
        .GetSuperConstructor(constructor);
  }

  // Check if the constructor is in fact a constructor.
  builder()->ThrowIfNotSuperConstructor(constructor);
}

Note omit_default_ctors: if the superclass constructor is a default constructor (one that just forwards its arguments), V8 skips calling it entirely and lets the subclass allocate the instance directly. That is a real performance win for class hierarchies, but it has observable consequences, which is why the optimisation is carefully disabled when a debugger is attached or when the array iterator has been monkey-patched.

new.target is passed as the construct target of the super call, so super() inside Sub allocates an object whose prototype is Sub.prototype, not Point.prototype. That is why JSFunction::GetDerivedMap needs the newTarget argument at all.

Private fields: a symbol, a brand, and a dictionary

Private names are Symbols with a special flag. The PrivateSymbolKind enum gives four flavours:

torque
// src/objects/name.tq:32
extern enum PrivateSymbolKind extends uint8 {
  kPublic,
  kInternal,
  kFieldName,
  kBrand
}

kFieldName is #x. kBrand is the marker installed on instances to prove they were constructed by that class. kInternal is for V8’s own bookkeeping.

A private field is stored as a normal own property, but the property name is the private symbol rather than a string. The property is non-enumerable, and crucially it is not discoverable through the prototype chain - private names are looked up with an OWN_SKIP_INTERCEPTOR configuration:

cpp
// src/objects/lookup-inl.h:387
LookupIterator::Configuration LookupIterator::ComputeConfiguration(
    Isolate* isolate, Configuration configuration, size_t index,
    DirectHandle<Name> name) {
  if (index != kInvalidIndex) return configuration;
  return name->IsAnyPrivate() ? OWN_SKIP_INTERCEPTOR : configuration;

So #x on an object whose prototype has a #x fails, which is the brand check for free: private names never look through the chain.

Adding the private field to an instance uses a dedicated path that forces DONT_ENUM:

cpp
// src/objects/lookup.cc:674
  if (!IsElement() && name_for_transition()->IsAnyPrivate()) {
    attributes = static_cast<PropertyAttributes>(attributes | DONT_ENUM);
  }

The brand is a different mechanism. When a class has private methods, V8 installs a brand symbol on every instance at construction time. The runtime entry point stores a reference to the class’s context in the instance, keyed by the brand symbol:

cpp
// src/runtime/runtime-object.cc:278
RUNTIME_FUNCTION(Runtime_AddPrivateBrand) {
  HandleScope scope(isolate);
  DCHECK_EQ(args.length(), 4);
  DirectHandle<JSReceiver> receiver = args.at<JSReceiver>(0);
  DirectHandle<Symbol> brand = args.at<Symbol>(1);
  DirectHandle<Context> context = args.at<Context>(2);
  int depth = args.smi_value_at(3);
  DCHECK(brand->is_any_private_name());

  LookupIterator it(isolate, receiver, brand, LookupIterator::OWN);

  if (it.IsFound()) {

The brand is DONT_ENUM | DONT_DELETE | READ_ONLY, and re-adding it is a TypeError - you cannot re-brand an object. That is the “private symbol dictionary” you may have heard about: private names live in the same property storage as everything else, but they are stored under symbols that only the engine and the defining class can name.

Private methods are not stored on the instance at all. They live in the class’s context, and the brand check finds them by walking the context chain. CollectPrivateMembersFromReceiver in runtime-object.cc does exactly that: it reads the brand’s context, walks to the class scope, and finds the method.

instanceof: two different algorithms

x instanceof C is not one operation. The spec has two.

First, Object::InstanceOf looks up @@hasInstance on C:

cpp
// src/objects/objects.cc:1123
MaybeHandle<Object> Object::InstanceOf(Isolate* isolate,
                                       DirectHandle<JSAny> object,
                                       DirectHandle<JSAny> callable) {
  // The {callable} must be a receiver.
  if (!IsJSReceiver(*callable)) {
    THROW_NEW_ERROR(isolate,
                    NewTypeError(MessageTemplate::kNonObjectInInstanceOfCheck));
  }

  // Lookup the @@hasInstance method on {callable}.
  DirectHandle<Object> inst_of_handler;
  ASSIGN_RETURN_ON_EXCEPTION(

If a handler exists, it is called and its result is coerced to a boolean. This is how Symbol.hasInstance lets you write a custom instanceof, and why Object.create(null) objects can be used as right-hand operands.

If there is no handler, we fall through to OrdinaryHasInstance, which is the chain walk. Function.prototype[Symbol.hasInstance] is that function:

torque
// src/builtins/function.tq:10
javascript builtin FunctionPrototypeHasInstance(
    js-implicit context: NativeContext, receiver: JSAny)(value: JSAny): JSAny {
  return OrdinaryHasInstance(context, receiver, value);
}

The runtime version of OrdinaryHasInstance reads .prototype off the callable with a full property lookup (it must, because .prototype can be overridden) and then calls HasInPrototypeChain, which walks the chain with a PrototypeIterator:

cpp
// src/objects/js-objects.cc:284
Maybe<bool> JSReceiver::HasInPrototypeChain(Isolate* isolate,
                                            DirectHandle<JSReceiver> object,
                                            DirectHandle<Object> proto) {
  PrototypeIterator iter(isolate, object, kStartAtReceiver);
  while (true) {
    if (!iter.AdvanceFollowingProxies()) return Nothing<bool>();
    if (iter.IsAtEnd()) return Just(false);
    if (PrototypeIterator::GetCurrent(iter).is_identical_to(proto)) {
      return Just(true);
    }
  }
}

But the compiled version is much sharper, because in generated code you know the constructor’s shape. CodeStubAssembler::OrdinaryHasInstance checks that the callable is a JSFunction with a prototype, loads the prototype directly out of the function, and then walks the chain by loading map and map.prototype in a loop - no PrototypeIterator, no proxy handling unless the instance type says to bail out:

cpp
// src/codegen/code-stub-assembler.cc:13714
TNode<Boolean> CodeStubAssembler::OrdinaryHasInstance(
    TNode<Context> context, TNode<Object> callable_maybe_smi,
    TNode<Object> object_maybe_smi) {
  TVARIABLE(Boolean, var_result);
  TVARIABLE(JSAny, var_non_instance_prototype);
  Label return_runtime(this, Label::kDeferred), return_false(this),
      return_result(this);
  Label if_non_instance_prototype(this, {&var_non_instance_prototype},

The loop itself is four instructions per step:

cpp
// src/codegen/code-stub-assembler.cc:13682
    // Check the current {object} prototype.
    TNode<HeapObject> object_prototype = LoadMapPrototype(object_map);
    GotoIf(IsNull(object_prototype), &return_false);
    GotoIf(TaggedEqual(object_prototype, prototype), &return_true);

    // Continue with the prototype.
    CSA_DCHECK(this, TaggedIsNotSmi(object_prototype));
    var_object_map = LoadMap(object_prototype);

And TurboFan goes one step further: it inlines the @@hasInstance lookup using the normal property-access machinery and then, if there is no handler, lowers the whole thing to a OrdinaryHasInstance node with a DependOnStablePrototypeChains dependency:

cpp
// src/compiler/js-native-context-specialization.cc:644
  if (access_info.IsNotFound()) {
    // If there's no @@hasInstance handler, the OrdinaryHasInstance operation
    // takes over, but that requires the constructor to be callable.
    if (!receiver_map.is_callable()) return NoChange();

    dependencies()->DependOnStablePrototypeChains(

Making the chain fast: validity cells and prototype users

Now the hard part. If a prototype’s Map changes, every Map that points at it is stale, and every optimized function that hard-coded a property offset found through that prototype is wrong.

V8’s answer has two halves.

Half one: the prototype user registry. Every prototype map keeps a PrototypeInfo with a prototype_users field - a WeakArrayList of weak references to the maps that use this prototype:

cpp
// src/objects/prototype-info.h:115
// A growing array with an additional API for marking slots "empty". When adding
// new elements, we reuse the empty slots instead of growing the array.
class V8_EXPORT_PRIVATE PrototypeUsers : public WeakArrayList {

Registration is lazy and walks up the chain, so registering a prototype registers it with all its ancestors:

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;

The registry_slot field is the trick that makes this terminate early: once a map is registered with its prototype, the walk stops, because the invariant is that registration up the chain is transitive.

Half two: the validity cell. Each prototype has a Cell whose value means “the prototype chain through me is still valid”. Handlers that resolve a property through the chain embed the cell and check it on every execution:

cpp
// src/objects/map.h:824
  // [prototype_validity_cell]: Cell containing the validity bit for prototype
  // chains or Tagged<Smi>(0) if uninitialized.
  // The meaning of this validity cell is different for prototype maps and
  // non-prototype maps.
  // For prototype maps the validity bit "guards" modifications of prototype
  // chains going through this object. When a prototype object changes, both its
  // own validity cell and those of all "downstream" prototypes are invalidated;
  // handlers for a given receiver embed the currently valid cell for that
  // receiver's prototype during their creation and check it on execution.
  // For non-prototype maps which are used as transitioning store handlers this
  // field contains the validity cell which guards modifications of this map's

When the prototype changes, V8 walks the user list and invalidates every downstream cell:

cpp
// src/objects/js-objects.cc:5375
void InvalidateOnePrototypeValidityCellInternal(Tagged<Map> map) {
#if V8_ENABLE_WEBASSEMBLY
  DCHECK(map->is_prototype_map() || IsAnyWasmObjectMap(map));
#else
  DCHECK(map->is_prototype_map());
#endif  // V8_ENABLE_WEBASSEMBLY

and the invalidation is a single store of a sentinel into the cell:

cpp
// src/objects/js-objects.cc:5386
  Tagged<Object> maybe_cell = map->prototype_validity_cell(kRelaxedLoad);
  if (maybe_cell != Map::kNoValidityCellSentinel) {
    // Just set the value; the cell will be replaced lazily.
    Tagged<Cell> cell = Cast<Cell>(maybe_cell);
    if (cell->maybe_value() != Map::kPrototypeChainInvalid) {
      cell->set_maybe_value(Map::kPrototypeChainInvalid, SKIP_WRITE_BARRIER);
    }
  }

Note “the cell will be replaced lazily”. Invalidation does not allocate a new cell; it just poisons the old one. The next GetOrCreatePrototypeChainValidityCell sees the poisoned value and makes a fresh one.

The invalidation walks the prototype users, not the prototype chain, which is the important asymmetry: invalidation must reach everything downstream of the change, and the user list is precisely the set of things downstream.

Finally, invalidation also clears the cached “load handler for a non-existent property” that the prototype holds, because that handler is exactly the thing that has become wrong:

cpp
// src/objects/js-objects.cc:5394
  Tagged<PrototypeInfo> prototype_info;
  if (map->TryGetPrototypeInfo(&prototype_info)) {
    prototype_info->set_prototype_chain_enum_cache(Smi::zero());
    // Previously created non-existent data handlers might no longer be valid,
    // ensure they are re-created if necessary.
    for (int i = 0; i < PrototypeInfo::kCachedHandlerCount; i++) {
      prototype_info->set_cached_handler(i, Smi::zero(), SKIP_WRITE_BARRIER);
    }

And for constant-tracked properties in dictionary-mode prototypes, invalidation also deoptimizes a whole dependency group, because a constant that was folded into optimized code might now be shadowed:

cpp
// src/objects/js-objects.cc:5404

  // We may inline accesses to constants stored in dictionary mode prototypes in
  // optimized code. When doing so, we install dependencies of group
  // |kPrototypeCheckGroup| on each prototype between the receiver's immediate
  // prototype and the holder of the constant property. This dependency is used
  // both to detect changes to the constant value itself, and other changes to

Trade-offs

What it buys. Prototype lookup in optimized code is a map load plus an offset load. The prototype chain is not walked at runtime; it was walked at compile time and the answer was frozen into the code, guarded by a validity cell. Adding a method to a prototype is an O(1) invalidation of the cell, not an O(n) fixup of every object.

What it costs.

  • One extra word per constructor. The prototype_or_initial_map field is overloaded, but the union means a Tuple2 allocation when .prototype is set to a non-object.
  • A PrototypeInfo per prototype. Lazily created, but real: it holds the user list, the derived-map cache, the registry slot, the enum cache and the cached handlers.
  • Weak caches everywhere. Derived maps and prototype users are weak, so they can vanish at any GC and have to be rebuilt. That is the price of not pinning Maps alive forever.
  • Invalidation is coarse. Poisoning the validity cell kills all optimized code that looked through that prototype, even if the change was unrelated to the property being read. In a program that mutates prototypes in a hot loop, this is a deoptimization storm.
  • Subclassing costs a Map copy. class Sub extends Base cannot reuse Base’s initial map, because the prototype differs. V8 copies the map and caches the copy in derived_maps.

The bottom line

The prototype chain in V8 is not a runtime data structure. It is a compile time one, materialised into Maps, guarded by a validity cell, and thrown away and rebuilt when the world changes underneath it.

The mental model to keep: a prototype is a shape. Map knows its prototype, PrototypeInfo knows who is using it, and a one-word store into a Cell is enough to tell every optimized function in the program that its cached answer is stale. That is the same bargain V8 makes for property transitions - share aggressively, invalidate cheaply - applied one level up the object graph.

Two details are worth remembering because they explain surprising behaviour: the superclass constructor is the constructor function’s own prototype (so super() is a map load, not a lookup), and derived maps are cached on the prototype in a weak list, so Object.create and Reflect.construct reuse shapes but never keep them alive.

The thing people get wrong

The common misconception is that a prototype chain is a linked list of objects that is walked on every property access. It is not, in optimized code. The walk happened when the code was compiled; what remains at runtime is a validity check.

The second misconception is that class X {} creates a single object with some kind of class record attached. It creates two heap objects and a Map, and the relationship between them is maintained by the constructor’s overloaded prototype_or_initial_map field - which is why you can, in principle, observe X.prototype change while X’s instances keep their old shape until the next allocation.

The third is that private fields are invisible to the object model. They are ordinary own properties under a private symbol, marked non-enumerable and looked up with OWN_SKIP_INTERCEPTOR, and the brand that gates private methods is itself just another such property.

V8InternalsObjectsClassesPerformance