Prototypes inside the engine
Learn how prototype maps, validity cells, and shape teleporting let engines cache inherited lookups without breaking JavaScript semantics.
- 01Explain an inherited lookup fast pathTrace how an engine can turn
obj.methodinto a receiver-map check, a validity-cell check, and a direct load from the prototype holder. - 02Separate semantics from engine strategyDescribe what JavaScript promises for shadowing and prototype changes, then identify which parts are implementation details in V8 and SpiderMonkey.
- 03Predict prototype mutation costsDecide when a change flips validity cells, changes receiver maps, deoptimizes call targets, or leaves an inherited-load cache usable.
Prototype lookup needs fast guards
JavaScript's prototype chain is flexible. A property can live on the receiver, on its direct prototype, or higher up the chain. That flexibility is language semantics: engines must return the same value even after shadowing, method replacement, or Object.setPrototypeOf changes the chain.
Prototype internals are the engine strategies that make inherited property lookup fast: prototype-specific maps, validity cells, and guarded inline caches that can jump straight to the prototype holding a property when the chain is still safe.
We will not repeat the language semantics covered in Prototype chain, Object.create, Class inheritance, and Native prototypes. Instead, we will look at the engine side. The previous batch lesson, Functions & closures inside the engine, explained function objects and feedback cells. This lesson connects that to inherited methods.
const proto = { label: "from prototype", method() { return "old method"; },};const child = Object.create(proto); console.log(child.label);child.label = "own label";console.log(child.label);console.log(proto.label);proto.method = function method() { return "new method";};console.log(child.method());Object.setPrototypeOf(child, { label: "new prototype" });delete child.label;console.log(child.label);Line 8 reads label from the prototype. Line 9 creates an own property that shadows it, so line 10 prints the own value while line 11 proves the prototype is unchanged. Lines 13 through 16 replace the method on the prototype, and the instance sees the new method. Lines 17 and 18 then change the prototype link and remove the shadow, so the next lookup reaches the new prototype.
Check your own recipe note first, then the family recipe book. If the book's current-page mark is still valid, you can return to that page quickly. When someone changes the page, the old mark can no longer be trusted.
- In real life: Check your own recipe note first
- In JavaScript: Lookup starts on the receiver object
- In real life: Shared recipes live in the family book
- In JavaScript: Methods often live on the prototype
- In real life: A mark says the page is current
- In JavaScript: A validity cell lets a cache trust the prototype chain
- In real life: A changed page clears the old mark
- In JavaScript: Prototype mutations make old caches miss
Where the analogy stops: A recipe book does not run code. JavaScript has many objects and call sites, and engines must keep exact [[Get]] semantics.
Prototype maps
A map or shape is engine metadata describing an object's layout. The neighbouring hidden-classes lesson goes deeper on map transition trees and descriptors. Here we only need one extra idea: prototypes get special bookkeeping because many receivers can depend on them for inherited lookups.
| Engine idea | Plain meaning | Why it matters for prototypes |
|---|---|---|
| Ordinary object map | Describes an object's own property layout and transitions. | Many objects can share it when their fields are added in the same order. |
| Prototype map | A map used by an object that other objects point to as a prototype. | V8 gives prototypes special bookkeeping such as prototype info and a validity cell. |
| Prototype link in a map | The receiver's shape/map can guard both property absence and the [[Prototype]] link. | Changing Object.setPrototypeOf(receiver, ...) gives the receiver a different map. |
| Validity cell | A small cell checked by cached inherited loads. | If a relevant prototype changes, old cells become invalid and existing caches miss. |
Mathias Bynens and Benedikt Meurer describe a key trick used by engines: the receiver's shape can include the prototype link. A single receiver-map check can therefore prove both “this object still lacks the property” and “this object still points to that prototype.”
const proto = { kind: "rect", getArea() { return 12; },};console.log( `Node ${process.versions.node} (V8 ${process.versions.v8.split(".").slice(0, 2).join(".")})`,);console.log("before fast properties", %HasFastProperties(proto));const rect = Object.create(proto);console.log("after used as prototype", %HasFastProperties(proto));%DebugPrint(proto);void rect;The tests run this in Node 22 with V8 12.4 and assert stable facts only: the debug print contains prototype_map, prototype info, and prototype_validity cell. In this build, %HasFastProperties(proto) is true before the object becomes a prototype and false after; that does not mean prototype lookups are slow, only that prototype bookkeeping is not the same as ordinary fast-property storage.
The important lesson is not the exact DebugPrint text. It is that a prototype is still a JavaScript object, but the engine marks and tracks it differently once other objects depend on it.
Caching inherited property lookups
Every method call begins with a property load. rect.getArea() first loads getArea, then calls the function with rect as this. That first step can be expensive if the engine walks the chain every time.
Walk one inherited method call. The replay separates the receiver's own fields, the prototype link, the inherited method, and the final result.
script
getArea() { return this.width * this.height; },};const rect = { width: 3, height: 4 };Object.setPrototypeOf(rect, rectPrototype); console.log(rect.getArea());Step through the replay. Line 6 creates the receiver with width and height. Line 7 links the prototype. Line 9 asks for getArea. The method body on line 3 still reads data from the receiver, not from the prototype, because the call uses this = rect.
| Moment | What JavaScript does | What a cache can remember |
|---|---|---|
| First inherited read | Walk the receiver and prototypes using JavaScript semantics. | Attach a guarded recipe if the case is cacheable. |
| Cache hit | Check the receiver map and the cached validity cell. | Load the holder's slot directly; do not cache the old value. |
| Prototype shape change | The prototype's map changes and the old validity cell is no longer trusted. | The next read misses, walks again, and can attach a fresh cache. |
| Receiver prototype link change | The receiver gets a new map because the shape carries the prototype link. | The receiver-map guard fails even if the old cell is still valid. |
The inline-caches lesson introduced receiver-map guards for own properties and briefly mentioned prototype-chain ICs. Here the cached recipe is richer: receiver map, holder prototype, offset, and a validity cell that says the relevant chain has not changed.
Validity cells
A validity cell is a tiny piece of engine state that many optimized paths can check. In V8's prototype-load model, the cached load records the current cell. If a relevant prototype changes, the old cell becomes invalid. The next cache hit fails the cell check and falls back to a fresh lookup.
Watch a cached inherited load: first miss, second hit, prototype mutation invalidates the old cell, then the next lookup refreshes the cache.
script
const cache = { receiverMap: null, holder: null, offset: null, cell: null };const rectProto = { slots: { getArea() { return 12; } } };const rect = { map: "RectMap#1", prototype: rectProto }; function loadGetArea(receiver) { if (cache.receiverMap === receiver.map && cache.cell?.valid) { return cache.holder.slots[cache.offset]; } const holder = receiver.prototype; cache.receiverMap = receiver.map; cache.holder = holder; cache.offset = "getArea"; cache.cell = validityCell; return holder.slots.getArea;} console.log(loadGetArea(rect)());console.log(loadGetArea(rect)());validityCell.valid = false;validityCell = { valid: true };rectProto.slots.getArea = () => 20;console.log(loadGetArea(rect)());The important detail is that the cache does not store the value 12. It stores a safe path to the slot. After the mutation, line 23 returns 20 because JavaScript semantics win. The cost is paid by invalidating old assumptions and refreshing the cache.
A valid cell only means “the prototype-chain facts this cache depended on are still true.” It does not say the method is pure, fast, or unchanged forever. Optimized calls may also keep separate dependencies on a specific function target.
V8's public prototype-optimization article says changing Object.prototype is especially broad: it can invalidate prototype loads below it, including DOM-related chains in browsers. That is why patching global prototypes after an app has warmed up is expensive.
Shape teleporting in SpiderMonkey
SpiderMonkey, Firefox's engine, uses the word shape where V8 often says map. Its “shape teleporting” optimization answers the same pressure: long prototype chains should not require a guard for every intermediate object on every hot lookup.
Naive prototype-chain cache for obj.y: GuardShape(obj) GuardShape(obj.[[Prototype]]) GuardShape(intermediatePrototype) GuardShape(holderPrototype) LoadSlot(holderPrototype, "y") SpiderMonkey shape teleporting model: GuardShape(obj) GuardShape(holderPrototype) LoadSlot(holderPrototype, "y") Reshape holders if an intermediate prototype starts shadowing "y"Matthew Gaudet's SpiderMonkey post explains the rule carefully: if a property is found on holder prototype B, the cache would like to guard only the receiver and B. That is safe only if SpiderMonkey reshapes objects when an intermediate prototype starts shadowing the property, or when prototype links mutate.
A note can send a student straight to the classroom with a worksheet instead of checking every room. If a nearer classroom starts handing out the same worksheet, the note must change immediately or the student goes to the wrong place.
- In real life: A student asks for a worksheet
- In JavaScript: A lookup asks for a property
- In real life: A note points to the right classroom
- In JavaScript: The cache knows the holder prototype
- In real life: A nearer classroom starts handing it out
- In JavaScript: Shadowing forces reshaping so old shortcuts fail
- In real life: Classrooms move, so the note is removed
- In JavaScript: Prototype mutation invalidates shortcut assumptions
Where the analogy stops: A paper note cannot update itself. Shape teleporting is mechanical: SpiderMonkey reshapes at the right mutations so shortcuts stay correct.
This is an engine strategy, not a new JavaScript feature. The specification still says lookup walks the chain. Shape teleporting is how SpiderMonkey can make the common case behave as if it walked, while doing fewer checks.
Why changing a prototype is slow
Object.setPrototypeOf and __proto__ assignment are legal, but they change the assumptions optimized code uses. MDN warns that changing an object's [[Prototype]] is slow in modern engines, and the effects can extend beyond the single statement because other code may have optimized around the old chain.
const proto = { getArea() { return 12; },};const rect = Object.create(proto); function area(object) { return object.getArea();} %PrepareFunctionForOptimization(area);for (let i = 0; i < 10000; i += 1) area(rect);%OptimizeFunctionOnNextCall(area);console.log("before", area(rect));proto.getArea = function getArea() { return 20;};console.log("after", area(rect));The lesson test runs this with --allow-natives-syntax --trace-opt --trace-deopt --no-concurrent-recompilation. It proves the JavaScript output changes from before 12 to after 20, and V8 prints a stable “marking dependent code … for deoptimization, reason: code dependencies” line after the prototype method is replaced.
Replacing a method value is not identical to adding a property. A simple inherited load can still know the slot, but optimized call code may have inlined or guarded the old function target. Engines keep separate dependencies so the next call remains correct.
Experiment: mutate the prototype chain
Use this teaching model to separate three outcomes: a validity-cell invalidation, a receiver-map miss, and a lookup that can stay cached. Start with two reads so the cache has something to reuse, then try each mutation.
const site = createInheritedLoadSite("getArea");const rect = makeRect(3, 4); site.read(rect); // cold miss, installs cachesite.read(rect); // cache hitrect.width = 5; // cached lookup can stayrect.__proto__.perimeter = 18; // prototype map + validity cell changesite.read(rect); // validity-cell miss, then refreshed cacheRectMap#2ProtoMap#1cell#1trueemptyemptyemptygetAreaNo reads yet. Press Read area to attach the first cache entry.
Start by reading getArea, then mutate the receiver or prototype and read again.
- Add
perimetertoRect.prototypeafter caches are warm. - Delete a helper from
Object.prototypeafter using it. - Call
Object.setPrototypeOf(rect, otherMethods)on a receiver. - Assign
rect.getArea = function () { return 99; }. - Assign
rect.width = 5whilegetAreastill lives on the prototype. - Replace
Rect.prototype.getAreawith a new function at the same property.
Sort each mutation by the guard it breaks in the lesson's teaching model.
Practical use
Most developers should not micro-manage prototype maps. The practical advice is simple: create objects with the prototype you want, avoid changing prototypes on hot paths, avoid patching native prototypes after startup, and measure before rewriting readable code.
const rectMethods = { getArea() { return this.width * this.height; },}; const card = Object.create(rectMethods);card.width = 3;card.height = 4;console.log(card.getArea()); const nextCard = Object.create(rectMethods);nextCard.width = 5;nextCard.height = 2;console.log(nextCard.getArea());Lines 7 and 12 use Object.create to choose the prototype at construction time. That is different from taking a warmed object and changing its prototype link later. If a framework or polyfill must patch a prototype, doing it before the app's hot code runs is less disruptive than changing it mid-flight.
- Create objects with
Object.create(proto), a class, or a constructor instead of changing prototypes later. - Do not patch
Object.prototypeor native prototypes in application hot paths. - Changing ordinary receiver data is fine; the method result can change without invalidating the inherited method lookup.
- Use a profiler before changing architecture for prototype performance.
The next lesson, The heap & pointer compression, moves from prototype metadata to how values and objects are placed in memory.
Common misconceptions
- “The engine caches inherited values.” It caches guarded lookup recipes; the value in the slot can still change.
- “Changing one prototype only costs that one line.” It can invalidate optimized code that depended on the old chain.
- “A prototype map is a JavaScript feature.” Maps, shapes, and validity cells are implementation details.
- “Shape teleporting skips semantics.” It skips repeated checks only when reshaping rules keep semantics correct.
- “All prototype mutations are equally bad.” Adding a prototype property, replacing a method value, changing receiver data, and setting a new prototype break different assumptions.
| Idea | Means | Do not confuse it with |
|---|---|---|
| Prototype-chain semantics | The specification says how [[Get]] searches own properties and prototypes. | It does not require maps, validity cells, inline caches, or shape teleporting. |
| Prototype lookup cache | A guarded recipe for where a property was found on a prototype. | A permanent copy of the property value. |
| Changing a method value | JavaScript must call the new function on the next lookup. | The same thing as adding or deleting a property; the slot may stay in place while call-target dependencies deopt. |
| Shape teleporting | SpiderMonkey can skip intermediate prototype shape checks when reshaping rules keep it correct. | Skipping JavaScript's observable prototype-chain rules. |
Practice exercises
Type the three values printed by the snippet.
const proto = { price: 100 };
const item = Object.create(proto);
console.log(item.price);
item.price = 120;
console.log(item.price);
console.log(proto.price);It prints 100, then 120, then 100. The own property shadows the prototype without changing it.
What does item.tag() print after the prototype method is replaced?
const proto = { tag() { return "old"; } };
const item = Object.create(proto);
proto.tag = function tag() { return "new"; };
console.log(item.tag());It prints new. A cache may remember the slot, but JavaScript still reads the current slot value.
Which guard does V8 check besides the receiver map for a cached inherited load?
The second guard is the prototype validity cell. It lets many caches fail cheaply when a prototype-chain assumption changes.
In the runnable Object.setPrototypeOf exercise from the tests, what label prints after switching to the new prototype?
// Bug: this runs after the app has warmed up.
Object.setPrototypeOf(card, premiumCardMethods);const card = Object.create(premiumCardMethods);Create the object with the right prototype up front instead of mutating a hot object's prototype link.
Your app patches a prototype during a scroll interaction. Name one safer habit from the practical section.
Good answers include: create objects with the right prototype, mutate prototypes before warm code runs, avoid patching Object.prototype, and measure first.
Which SpiderMonkey optimization lets a cache jump from receiver shape to holder shape in the lesson?
The optimization is shape teleporting. It is safe only because SpiderMonkey reshapes objects when shadowing or prototype mutation would break the shortcut.
Check your understanding
Answer by separating JavaScript semantics from the engine guard that keeps a fast path safe.
Question 1 of 7What does a cached inherited property load usually guard in the V8 model from the lesson?
Choose an answer to see the explanation.
Question 2 of 7What does this shadowing snippet print?
Read the code, then predictconst proto = { price: 100 }; const item = Object.create(proto); console.log(item.price); item.price = 120; console.log(item.price); console.log(proto.price);Choose an answer to see the explanation.
Question 3 of 7Why does changing a prototype link with
Object.setPrototypeOfhurt optimized code?Choose an answer to see the explanation.
Question 4 of 7What does this method replacement print?
Read the code, then predictconst proto = { tag() { return "old"; } }; const item = Object.create(proto); proto.tag = function tag() { return "new"; }; console.log(item.tag());Choose an answer to see the explanation.
Question 5 of 7What is SpiderMonkey's shape teleporting trying to avoid?
Choose an answer to see the explanation.
Question 6 of 7Which change can leave the inherited lookup cache usable, while still changing the method's result?
Choose an answer to see the explanation.
Question 7 of 7What did the Node/V8 debug probe prove for this lesson?
Choose an answer to see the explanation.
Key takeaways
- Inherited method calls begin with a property lookup; caches speed up that lookup without changing
thisor shadowing rules. - V8 prototype maps carry prototype-specific bookkeeping such as prototype info and prototype validity cells.
- A cached inherited load can check the receiver map and validity cell, then load directly from the holder prototype's slot.
- SpiderMonkey shape teleporting skips intermediate prototype guards only because reshaping rules invalidate unsafe shortcuts.
- Changing prototypes or patching global prototypes after warm-up can invalidate broad engine assumptions; create with the right prototype and measure first.
One-line summary: prototype fast paths work by trusting guarded chain facts until a prototype or receiver-map change proves those facts stale.
Next: The heap & pointer compression explains where these objects, maps, and values live in memory.