Inline caches
Learn how JavaScript engines cache property lookups at each call site, move from monomorphic to megamorphic, and feed JIT optimizers.
- 01Explain a property access fast pathDescribe how one access site remembers a shape/map and property offset so the next matching object needs only a guard and load.
- 02Read IC statesDistinguish uninitialized, monomorphic, polymorphic, and megamorphic sites, including V8's four-map polymorphic limit.
- 03Connect IC feedback to production codeRecognize keyed, store, call, global, and prototype-chain ICs, then write shape-friendly code without over-optimizing blindly.
Lookups become remembered work
A naive property lookup is a search. The engine checks the object, searches its own properties, and then walks the prototype chain until it finds a matching name or gives up. That flexible rule is why obj.x works even when x lives on a prototype.
An inline cache is feedback stored at a specific operation site. For a property load, it remembers facts like “objects with shape S store property x at offset k,” so the next matching object can be handled by a shape check and one load.
The “inline” part is historical: early Smalltalk and JavaScript engines patched a tiny fast path near the operation. Modern engines may store feedback in vectors and stubs instead, but the mental model is the same: each call site learns from the shapes it actually sees.
const record = { user: { name: "Ada" } };const proto = { role: "author" };Object.setPrototypeOf(record.user, proto); function naiveLookup(object, name) { let current = object; while (current) { if (Object.prototype.hasOwnProperty.call(current, name)) { return current[name]; } current = Object.getPrototypeOf(current); } return undefined;} console.log(naiveLookup(record.user, "name"));console.log(naiveLookup(record.user, "role"));Line 6 asks whether the current object owns the property. Line 10 moves to the prototype. Engines cannot afford that search on every hot read, so they attach a specialized stub after the first miss proves where the property lives.
Imagine a carrier delivering to one building entrance all day. The first visit needs a directory. After that, “street shape plus box number” is enough, until the route includes too many unrelated buildings.
- In real life: A new street requires checking the directory
- In JavaScript: The first shape at a site uses the slow lookup
- In real life: After learning the route, the carrier checks the street sign
- In JavaScript: The IC checks the object's shape/map
- In real life: The same house number is found in the same slot
- In JavaScript: The property offset is loaded directly
- In real life: Too many apartment layouts need the central desk
- In JavaScript: A megamorphic site falls back to a stub cache or generic lookup
Where the analogy stops: Mail slots do not mutate while the carrier is walking. JavaScript objects and prototypes can change, so engines keep guards, validity checks, and deoptimization paths.
This lesson sits after type feedback and before deeper hidden-class lessons. If the words shape, map, and hidden class are new, use the small definition here and revisit object basics, the prototype chain, classes, and property descriptors. The later Hidden classes & object shapes lesson goes deeper.
Shapes, maps, and offsets
Engines use different names, but the useful idea is shared: objects with the same property layout share a hidden class, map, or shape. That layout stores where each property lives. If x is at offset 0 for shape S, the engine can load slot 0 after checking S.
function makeUser(name) { return { name, score: 0, active: true };} const ada = makeUser("Ada");const lin = makeUser("Lin");const reordered = { score: 0, name: "Grace", active: true }; console.log(Object.keys(ada).join(","));console.log(Object.keys(lin).join(","));console.log(Object.keys(reordered).join(","));This snippet only prints key order because browser JavaScript does not expose maps. The real evidence below uses Node 22.23.1 with V8 native syntax in a child process. It is marked non-runnable in the browser editor on purpose.
%HaveSameMapJavaScriptfunction Point(x) { this.x = x;} const a = { x: 1, y: 2 };const b = { x: 3, y: 4 };const c = { y: 2, x: 1 };const p = new Point(1);const q = new Point(2); console.log(`Node ${process.versions.node} (V8 ${process.versions.v8.split(".").slice(0, 2).join(".")})`);console.log("same property order", %HaveSameMap(a, b));console.log("different property order", %HaveSameMap(a, c));console.log("constructor start", %HaveSameMap(p, q));p.y = 2;console.log("after one transition", %HaveSameMap(p, q));q.y = 3;console.log("after both transition", %HaveSameMap(p, q));Tests run this code in Node 22.23.1 (V8 12.4). Same property order reports true, different order reports false, and adding a property to one constructor instance makes it diverge until the other instance takes the same transition.
The V8 team explains the internal property-store distinction in Fast properties in V8, and the V8 maps docs show that a map is the first pointer used to compare an object's hidden class. Mathias Bynens and Benedikt Meurer's Shapes and Inline Caches article is the high-level companion.
Monomorphic inline caches: one shape, one load
Inline caches belong to operation sites, not to property names globally. Two different obj.x expressions have separate feedback. At one site, the first object misses, the engine runs the slow lookup, then attaches a stub for that shape and offset.
Watch one property access site learn a shape, then turn the next same-shaped read into a shape check plus one load.
script
function createGetXSite() { let cachedShape = null; let cachedOffset = "x"; return function getX(record) { if (record.shape === cachedShape) { return record.slots[cachedOffset]; } cachedShape = record.shape; return record.slots.x; };} const a = { shape: "x,y", slots: { x: 1, y: 10 } };const b = { shape: "x,y", slots: { x: 2, y: 20 } };console.log(getX(a));console.log(getX(b));Step through the replay. The first getX(a) call records the shape and the offset for x. The second call receives a different object with the same shape, so the IC can do exactly what optimizing compilers want: check the map, then load the slot.
The IC does not cache the value 1. It caches a safe recipe: if the receiver has this shape, read this offset. If the shape check fails, the site misses and gathers new feedback.
Polymorphic to megamorphic
Real programs often send two or three stable layouts through one helper. That is polymorphic feedback: the site has a short list of shape-specific stubs. In V8 12.4, the source and tests for this lesson verify the polymorphic map budget as four; a fifth distinct map pushes the site into megamorphic handling. Treat the exact number as implementation detail, not JavaScript semantics.
Step through one access site as it grows from monomorphic to polymorphic, then falls back to a megamorphic stub cache.
script
const site = { state: "uninitialized", entries: [], stubCache: new Map() }; function readX(record) { const key = record.shape + ":x"; if (site.state === "megamorphic" && site.stubCache.has(key)) { return site.stubCache.get(key)(record); } if (site.entries.includes(record.shape)) { return record.slots.x; } if (site.entries.length === limit) { site.state = "megamorphic"; site.stubCache.set(key, (object) => object.slots.x); return record.slots.x; } site.entries.push(record.shape); site.state = site.entries.length === 1 ? "monomorphic" : "polymorphic"; return record.slots.x;} const records = [ { shape: "S1", slots: { x: 1 } }, { shape: "S2", slots: { x: 2 } }, { shape: "S3", slots: { x: 3 } }, { shape: "S4", slots: { x: 4 } }, { shape: "S5", slots: { x: 5 } },]; for (const record of records) console.log(readX(record));console.log(readX(records[4]));getX siteJavaScriptconst site = createInlineCacheSite("getX");const objects = [ makeObject([["x", 1], ["a", 10]], "x-a"), makeObject([["a", 20], ["x", 2]], "a-x"), makeObject([["x", 3], ["b", 30]], "x-b"), makeObject([["b", 40], ["x", 4]], "b-x"), makeObject([["c", 50], ["x", 5]], "c-x"),]; for (const object of objects) { console.log(site.read(object, "x"));}S2x → a
S4a → x
S5x → b
S7b → x
S9c → x
No reads yet. Start with the scripted button, then compare manual shapes.
Push an object through getX(obj). Same-layout objects should hit quickly; several different layouts push the site toward megamorphic.
| State | What the site has seen | Typical cost |
|---|---|---|
| Uninitialized | The site has no feedback yet. | First access must run the normal lookup and attach a stub if the case is cacheable. |
| Monomorphic | One shape/map has been seen for this property name. | One shape check and one offset load on a hit; this is the optimizer's favorite case. |
| Polymorphic | A small set of shapes has been seen. V8 12.4 uses four maps as the limit in its IC policy. | A short chain of checks or a compact multi-map guard; still useful feedback but more expensive than one shape. |
| Megamorphic | Too many shapes reached the same site. | The site stops growing local stubs and uses broader lookup machinery such as a stub cache keyed by map and name. |
V8's megamorphic named-load path uses broader lookup machinery and a stub cache keyed by information such as the receiver map and property name. That keeps one noisy site from growing unlimited local code, but it is slower than a tiny monomorphic guard.
getXreads objects created by one constructor that initializesxtheny.getXhandles API rows shaped{x, name}and chart points shaped{label, x}.- A hot formatter reads
.xfrom dozens of unrelated JSON objects. - A renderer sees four stable view-model shapes and no others.
- A loop reads
items[i]from packed arrays built by the same helper. - A plugin hook calls a different callback shape and target for every installed plugin.
Place each access pattern in the state it is likely to create at one hot site.
Keyed, store, call, and global ICs
Property loads are the easiest IC to picture, but feedback exists for many dynamic operations. A keyed IC for obj[key] has to separate named properties from element access. Arrays add another dimension: packed arrays, holey arrays, integer elements, doubles, and object elements each give different feedback.
function readKey(target, key) { return target[key];} const user = { name: "Ada" };const packed = ["zero", "one", "two"];const holey = [];holey[2] = "two"; console.log(readKey(user, "name"));console.log(readKey(packed, 1));console.log(0 in holey);console.log(readKey(holey, 2));Store ICs learn whether a write updates an existing slot or creates a shape transition. Call ICs learn callable targets and receiver shapes, which is why a monomorphic call site can later be inlined by an optimizing compiler.
function writeX(object, value) { object.x = value; return object.x;} const a = { x: 0, y: 0 };const b = { x: 1, y: 1 };const c = { y: 0 }; console.log(writeX(a, 7));console.log(writeX(b, 8));console.log(Object.keys(c).join(","));console.log(writeX(c, 9));console.log(Object.keys(c).join(","));function render(formatter, value) { return formatter(value);} function money(amount) { return "$" + amount.toFixed(2);} function label(value) { return "item:" + value;} console.log(render(money, 3));console.log(render(money, 4));console.log(render(label, "A"));Global load ICs do similar work for names such as Math or document. Prototype-chain loads cache not only the receiver shape, but also assumptions that the prototype chain has not changed. V8 describes these dependency checks with validity cells; the later hidden classes and prototypes inside the engine lessons go deeper.
const proto = { x: 1 };const child = Object.create(proto); console.log(child.x);proto.x = 2;console.log(child.x);child.x = 3;console.log(child.x);console.log(proto.x);| IC family | Syntax | Feedback it records |
|---|---|---|
| Named load IC | obj.x | Caches a named property lookup and usually records a shape/map plus offset. |
| Keyed load IC | obj[key] or array[index] | Separates named keys from element access; arrays also carry packed, holey, integer, double, and object element-kind feedback. |
| Store IC | obj.x = value | Caches writes, including whether the write updates an existing slot or transitions the object to a new shape. |
| Call IC | fn(arg) or obj.method() | Records callable targets and receiver shapes; monomorphic call feedback can let an optimizer inline the target. |
| Global load IC | document or Math | Caches global object or lexical environment lookups with guards that the binding cell is still valid. |
CacheIR and other engines
SpiderMonkey documents CacheIR as the core of its speculative optimization strategy: a simple linear bytecode for inline-cache stubs. The official docs describe guards, idempotent operations, and one final result operation. Their example for a fixed property load is essentially guard the input object, guard the shape, then load the fixed slot.
obj.xTextGetProp "x" GuardToObject input GuardShape shape=S1 LoadFixedSlotResult slot=0Jan de Mooij's CacheIR post explains why Firefox moved this logic into a structured representation. The current SpiderMonkey docs say Baseline stubs are generated from CacheIR and Warp can transpile CacheIR snapshots into optimizing compiler IR. JavaScriptCore uses inline caches too; WebKit's Speculation in JavaScriptCore describes how JSC speculates from runtime evidence and guards those assumptions.
V8 talks about maps, feedback vectors, IC stubs, and optimizing tiers. SpiderMonkey talks about shapes, Baseline IC chains, CacheIR, and Warp. JavaScriptCore talks about structures, inline caches, speculation, and tiers. The shared idea is guarded specialization.
How optimizers use IC feedback
Baseline tiers gather feedback while keeping code quick to generate. When a function gets hot, an optimizing compiler can look at the IC history. A monomorphic property load can become machine code shaped like “compare receiver map with S; if it fails, deopt; otherwise load memory at offset k.” That is the single-memory-load promise in the curriculum summary.
Polymorphic sites may still optimize with a small guard chain. Megamorphic sites are harder: they say “this operation sees many unrelated shapes,” so the compiler has less to specialize and often leaves a call to generic or stub-cache machinery. Call IC feedback works the same way: one stable target can be inlined; many targets make inlining less attractive.
You rarely need to chase IC states by hand. Use measuring performance first. If a profile points at a hot property read, then shape consistency is a useful hypothesis to test.
Practical shape-friendly code
The production advice is deliberately boring: initialize all expected properties, do it in the same order, avoid delete on hot objects, and avoid routing many unrelated object types through one tiny hot helper. Do not contort clear code for a theoretical IC win.
function User(name, admin = false) { this.name = name; this.admin = admin; this.score = 0;} const ada = new User("Ada");const lin = new User("Lin", true);lin.score = 10; console.log(Object.keys(ada).join(","));console.log(Object.keys(lin).join(","));delete ada.admin;console.log(Object.keys(ada).join(","));Lines 2 through 4 create the same layout for every User. Line 13 shows the habit to avoid in hot code: deleting a property changes the layout and can push an object away from fast-property storage. Sometimes deletion is the clearest model; just do not assume it is free inside a hot loop.
- Construct objects with the same fields in the same order.
- Prefer setting a field to
nullorundefinedoverdeletewhen the object remains hot. - Split a hot helper if it processes many unrelated shapes and profiling shows that site matters.
- Keep readability first, then measure before and after.
Common misconceptions
- “The IC caches values.” It caches a guarded lookup recipe; values can change.
- “One polymorphic site is always bad.” A small stable set of shapes is normal and often optimized well.
- “Megamorphic means broken.” It means the engine chose broader machinery for a diverse site; it matters only when profiling says it is hot.
- “Hidden classes are JavaScript semantics.” They are engine strategies. The language still says property lookup follows objects and prototypes.
- “Engine internals should dictate every style choice.” Clear data models, tests, and measurements beat folklore.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| Uninitialized | The site has no feedback yet. | First access must run the normal lookup and attach a stub if the case is cacheable. |
| Monomorphic | One shape/map has been seen for this property name. | One shape check and one offset load on a hit; this is the optimizer's favorite case. |
| Polymorphic | A small set of shapes has been seen. V8 12.4 uses four maps as the limit in its IC policy. | A short chain of checks or a compact multi-map guard; still useful feedback but more expensive than one shape. |
| Megamorphic | Too many shapes reached the same site. | The site stops growing local stubs and uses broader lookup machinery such as a stub cache keyed by map and name. |
Practice exercises
Read the snippet and type the two printed values.
function getX(object) {
return object.x;
}
const a = { x: 1, y: 10 };
const b = { x: 2, y: 20 };
console.log(getX(a));
console.log(getX(b));The calls print 1 and then 2. An engine may make this site monomorphic because both literals use the same property order.
How many maps fit in the V8 polymorphic IC budget discussed in this lesson?
The verified V8 12.4 limit used here is four maps. More than four pushes the site toward megamorphic handling.
What does the hole check print?
const holey = [];
holey[2] = "two";
console.log(0 in holey);It prints false because index 0 is a hole. Keyed IC feedback treats holey arrays differently from packed arrays.
Which CacheIR operation checks the object's shape in the lesson's sequence?
GuardShape is the operation that checks the object's shape before LoadFixedSlotResult reads the slot.
Name one practical habit that helps a hot site stay shape-friendly.
Good answers include initializing every expected property in the same order, avoiding delete on hot objects, avoiding many shapes at one hot site, and measuring before changing code.
Check your understanding
Question 1 of 7What does an inline cache remember for a normal
obj.xload?Choose an answer to see the explanation.
Question 2 of 7What does this shape-order snippet print?
Read the code, then predictconst a = { x: 1, y: 2 }; const b = { y: 2, x: 1 }; console.log(Object.keys(a).join("|")); console.log(Object.keys(b).join("|"));Choose an answer to see the explanation.
Question 3 of 7In V8 12.4, what happens after one property access site sees more than four maps?
Choose an answer to see the explanation.
Question 4 of 7What does the keyed example print for the hole check?
Read the code, then predictconst holey = []; holey[2] = "two"; console.log(0 in holey);Choose an answer to see the explanation.
Question 5 of 7Why is monomorphic call feedback valuable to an optimizing compiler?
Choose an answer to see the explanation.
Question 6 of 7Which CacheIR sequence best matches a simple fixed-slot property load?
Choose an answer to see the explanation.
Question 7 of 7Which production habit best supports stable shapes?
Choose an answer to see the explanation.
Key takeaways
- Inline caches are per-site feedback: one
obj.xexpression can be monomorphic while another is megamorphic. - Shapes/maps connect property names to offsets, so a cached load can become a shape guard plus one slot load.
- V8 12.4 uses a four-map polymorphic budget; too many shapes make a site megamorphic and push work to broader caches.
- Keyed, store, call, global, and prototype ICs all record different runtime facts for later tiers.
- Write consistent object shapes when it is natural, avoid hot
delete, and measure before optimizing.
Next in this module, Baseline compilers picks up where IC feedback leaves off: fast simple compilers that remove interpreter overhead before the highest optimizing tiers take over.