Inline caches
Learn how JavaScript engines cache property lookups at each call site, move from monomorphic to megamorphic, and feed JIT optimizers.
- 01Explain the fast way to read a propertyDescribe how one place in your code remembers an object's shape and the slot of a property, so the next matching object needs one check and one read.
- 02Name the cache statesTell apart a cache that has seen no shapes, one shape (monomorphic), a few (polymorphic), and too many (megamorphic). Know that the cut-off number is the engine's choice, not a language rule.
- 03Use this in real codeRecognize caches for square-bracket reads, writes, calls, global names, and prototypes. Then write objects with steady shapes, and measure before you optimize.
Remember where a property is
When you write obj.x, the JavaScript engine has to find x. The engine is the program inside the browser or Node.js that runs your code. By the rules of the language, finding x is a search:
- First the engine looks at the object's own properties.
- If
xis not there, it looks at the object's prototype. A prototype is another object that this object borrows properties from. - It keeps going up this line of prototypes, called the prototype chain, until it finds
xor runs out of objects.
This search is why obj.x works even when x lives on a prototype. But a search takes time, and a busy program may read the same property millions of times.
An inline cache is a small memory attached to one place in your code that reads or writes a property. It remembers where the property was found last time. For example: "for objects with this layout, x is in slot 0." When the next object has the same layout, the engine checks the layout once and reads slot 0. It skips the search.
The word "inline" comes from history. Early engines wrote a tiny shortcut right inside the code, next to the property read. Modern engines keep this memory in other places, but the idea is the same: each place in your code learns from the objects it 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"));This function copies the language's search rule into plain JavaScript:
- Line 6 starts at the object itself.
- Line 7 keeps going while there is an object to look at.
- Line 8 asks: does this object have the property as its own property?
- Line 9 returns the value when it does.
- Line 11 moves one step up, to the prototype.
So naiveLookup(record.user, "name") finds "Ada" on the object itself and prints Ada. The "role" search finds nothing on the object, moves to the prototype, and prints author. Doing this full search on every busy read would be too slow. So after the first search, the engine remembers where the property was.
A mail carrier visits a new building. The first time, she reads the map to find the mailbox for flat 4. Next time she sees the same kind of building, she knows the box is the third one. She only checks that the building looks the same. If her route has too many different buildings, she stops memorizing and asks the main desk instead.
- In real life: On a new street, the carrier reads the street map
- In JavaScript: The first object at a place in your code needs the slow search
- In real life: Next time, the carrier only checks the building type
- In JavaScript: The cache checks the object's layout
- In real life: In this building type, flat 4's box is always the third one
- In JavaScript: The engine reads the remembered slot directly
- In real life: With too many building types, the carrier asks the main desk
- In JavaScript: With too many layouts, the engine uses a shared table
This lesson builds on type feedback. It helps to know object basics, the prototype chain, classes, and property descriptors. The next section explains layouts in just enough detail for this lesson. The later Hidden classes & object shapes lesson goes deeper.
Shapes: a layout shared by similar objects
Engines keep a hidden record for each object layout. The record lists the property names in order and says where each value is kept. Each place is a numbered slot. The slot number is also called the offset.
Different engines give this record different names:
- V8 (Chrome and Node.js) calls it a map.
- SpiderMonkey (Firefox) calls it a shape.
- Other texts call it a hidden class.
This lesson says shape. Objects with the same property names, added in the same order, share one shape. If the shape says x is in slot 0, the engine can check the shape and then read slot 0. No search is needed.
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(","));Line 2 always adds name, then score, then active. So ada and lin have the same layout. Line 7 writes the same three names in a different order: score first. Object.keys lists an object's property names in order, so the last line prints score,name,active. That different order is a clue that reordered has a different shape.
ada and lin were made the same way, so they share one shape. Each object keeps only its values. The shape says which slot holds which name.Normal JavaScript can't see shapes. The code above only prints the order of the names. To see real shapes, the next example uses Node.js 22, which runs V8 version 12.4. It turns on a test-only feature with the flag --allow-natives-syntax. That flag adds helpers that start with %, such as %HaveSameMap(a, b). It answers: do a and b have the same map? This code can't run in the browser editor, so its Run button is turned off.
function 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));The lesson's tests run this code in Node.js 22 with V8 12.4. Here is what it printed:
- Same property order:
true. The two objects share one map. - Different order:
false. - Two new
Pointobjects:true. - After adding
ytoponly:false. Adding a property movespto a new map. - After adding
ytoqas well:true. Both took the same step, so they match again.
Want to read more? The V8 team explains how it stores properties in Fast properties in V8. The V8 maps docs show that the map is the first thing stored in every object. Mathias Bynens and Benedikt Meurer's Shapes and Inline Caches article gives a friendly overview.
One shape: one check and one read
An inline cache belongs to one place in your code, not to a property name. If your program has two different obj.x reads, each one has its own cache. Engines call such a place a site.
At a site, the first object finds the cache empty. That is a miss. The engine does the slow search, then saves a small, ready-made shortcut for that shape and slot. Engines call this shortcut a stub. A later object that matches the saved shape is a hit.
Watch one place in code learn a shape on the first call, then use one check and one read on the second call.
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));The replay is a simple model written in JavaScript. Each object carries a shape string, so you can watch the check happen. Step through it:
- The first
getX(a)call is a miss. It saves the shapex,yand returns1. - The second call,
getX(b), gets a different object with the same shape. The check on line 5 passes. - Line 6 reads the value straight away and returns
2.
x is. The second object has the same shape, so one check and one read are enough.A cache that has seen only one shape is called monomorphic ("mono" means one, "morph" means form). This is the best case. The engine needs only two steps: check the shape, then read the slot.
The cache does not save the value 1. It saves a safe instruction: "if the object has this shape, read this slot." The value in the slot can change, and the instruction still works. If the shape check fails, the site misses and learns again.
A few shapes, then too many
Real programs often send two or three different layouts through one helper function. Then the site has a short list of saved shapes, and it checks them one by one. A site like this is polymorphic ("poly" means many).
Each site keeps only a few shapes. When too many different shapes arrive, the site stops saving them. It is now megamorphic ("mega" means huge). From then on it asks a slower table shared by the whole engine. Engines call that table the stub cache.
This lesson's model lets a site keep 4 shapes, and the 5th shape makes it megamorphic. Real engines choose their own number. It can change between engines, kinds of cache, and versions. No number is part of the JavaScript language.
Step through one site as it goes from one shape, to a few, to too many, and then uses a shared table.
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]));const 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 first button, then try sending objects yourself.
Send an object through getX(obj). Objects with the same layout are quick hits. Many different layouts make the site megamorphic.
The table below lists the four states a site can be in. Read each row as: the site is in this state, it has seen this, and a read costs this much.
| State | What the site has seen | What a read costs |
|---|---|---|
| Uninitialized | Nothing yet. The code has not run. | The first read does the full slow search, then saves a shortcut if it can. |
| Monomorphic (one shape) | One shape for this property. | One shape check and one slot read. This is the fastest case. |
| Polymorphic (a few shapes) | A few shapes. How many it keeps depends on the engine. | A short list of shape checks. Still fast, but slower than one check. |
| Megamorphic (too many shapes) | Too many different shapes at the same place. | The site stops saving shortcuts and looks in a slower table shared by the whole engine. |
In V8, a megamorphic property read looks in the shared stub cache. It finds the entry using the object's map and the property name. This stops one busy site from saving more and more shortcuts forever. But it is slower than one quick shape check.
getXreads objects from one constructor that always setsx, theny.getXgets rows shaped{x, name}and chart points shaped{label, x}.- A busy formatter reads
.xfrom dozens of unrelated JSON objects. - A drawing function sees four steady object shapes and no others.
- A loop reads
items[i]from full arrays that one helper always builds. - A plugin hook calls a different function, with a different object shape, for every installed plugin.
Each card describes one busy place in code. Put it in the state that place most likely reaches.
Caches for keys, writes, calls, and global names
Reading obj.x is the easiest case to picture. But engines keep caches for many other operations too.
A keyed cache handles reads with square brackets, like obj[key]. The key can be a property name or an array index, so the cache has to tell them apart. Arrays add more cases:
- A packed array has a value at every index.
- A holey array has gaps, called holes, where no value was ever set.
- An array can hold only small whole numbers, only decimal numbers, or any values. The engine tracks which kind it is.
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));Line 1 reads with target[key]. Line 10 passes it a property name, and line 11 passes an index into a packed array. Line 8 sets only index 2 of holey, so indexes 0 and 1 are holes. The in operator asks whether a property exists, so line 12 prints false.
A store cache handles writes, like obj.x = value. It learns one of two things:
- The write changes a slot that already exists.
- The write adds a new property, so the object moves to a new shape. Engines call this move a transition.
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(","));Line 2 is the write. For a and b, x already exists, so the write only changes a slot. Line 12 shows that c has only y. Line 13 adds x to c, which is a transition. Line 14 then prints y,x.
A call cache remembers which function a call ran. If one call site always runs the same function, a faster compiler can later copy that function's body right into the caller. This is called inlining.
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"));Line 2 calls whatever function arrives as formatter. Lines 13 and 14 send money twice. Line 15 sends a different function, label. Now the call cache on line 2 has seen two functions.
Global caches do the same job for global names such as Math or document. Reads that go through a prototype need one more thing. The cache must remember the object's shape and check that the prototypes have not changed since. V8 keeps a small flag for this, called a validity cell. 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);Line 2 makes child with proto as its prototype. Line 4 prints 1, found on the prototype. Line 5 changes the prototype, so the same child.x on line 6 now prints 2. Line 7 gives child its own x, so line 8 prints 3. A saved shortcut must notice each change. That is why the engine checks before it trusts the cache.
The table below sums up the kinds of cache. Read each row as: this kind of cache, for code like this, remembers this.
| Kind of cache | Code it is used for | What it remembers |
|---|---|---|
| Named read cache | obj.x | The object's shape and the slot where x is kept. |
| Keyed read cache | obj[key] or array[index] | Whether the key is a name or an index. For arrays: full or with holes, and what kind of values they hold. |
| Write (store) cache | obj.x = value | Whether the write changes an existing slot or adds a property, which moves the object to a new shape. |
| Call cache | fn(arg) or obj.method() | Which function ran. If it is always the same one, a faster compiler can copy it into the caller. |
| Global name cache | document or Math | Where a global name was found, with a check that it has not changed since. |
How Firefox writes a cache: CacheIR
Firefox's engine, SpiderMonkey, writes each cache shortcut as a short list of simple steps. This list format is called CacheIR ("IR" means a simple in-between language that the engine turns into machine code). The list has two kinds of steps:
- Guards check that something is still true. If a check fails, the shortcut is skipped.
- One final result step produces the value.
The official docs show a simple obj.x read. It checks that the input is an object, checks its shape, and then reads a fixed slot.
GetProp "x" GuardToObject input GuardShape shape=S1 LoadFixedSlotResult slot=0In plain words, line by line:
- Line 1 names the operation: read the property
"x". - Line 2 is a guard: the input must be an object.
- Line 3 is a guard: the object must have shape
S1. - Line 4 is the result: read slot 0 of the object.
Jan de Mooij's CacheIR post explains why Firefox moved to this format. The SpiderMonkey docs say two of Firefox's compilers, Baseline and Warp, both build their code from these steps. Safari's engine, JavaScriptCore, uses inline caches too. WebKit's Speculation in JavaScriptCore explains how it guesses from what the code did so far, and checks each guess.
Each engine uses its own words:
- V8 (Chrome, Node.js): maps, feedback vectors, and stubs.
- SpiderMonkey (Firefox): shapes, CacheIR, and the Baseline and Warp compilers.
- JavaScriptCore (Safari): structures, inline caches, and speculation.
The job is the same in all three. Make a fast path from what the code did before, and put a check in front of it.
How faster compilers use what the cache learned
Engines run code in stages, called tiers. Early tiers start quickly and fill the caches as the code runs. When a function runs many times, an optimizing compiler turns it into fast machine code. It reads what the caches learned to decide what to build.
For a monomorphic read, the machine code can do just this:
- Check that the object has shape S.
- If not, leave the fast code and go back to the slower code. Engines call this deoptimizing.
- If yes, read the value at slot k in memory.
That is how one property read can become one shape check and one memory read.
A polymorphic site can still get fast code, with a short list of checks. A megamorphic site is harder. It says "this read sees many different shapes," so the compiler has less to plan around. It often keeps a call to the slower shared lookup. Call caches work the same way. One known function can be inlined. Many different functions make inlining less worth it.
You rarely need to track cache states by hand. Start with measuring performance. If a profile shows one property read is slow and very busy, then check whether its objects have mixed shapes.
Write objects with steady shapes
The advice for real code is simple:
- Create every property you expect, right away.
- Create them in the same order each time.
- Avoid
deleteon objects that busy code uses again and again. - Don't send many unrelated kinds of objects through one small, busy helper.
Don't twist clear code out of shape just to please the cache.
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 to 4 create the same three properties, in the same order, for every User. So ada and lin share one layout. Line 13 shows the habit to avoid in busy code: delete removes a property, which changes the layout. V8 may even switch the object to a slower way of storing properties. Sometimes delete is still the clearest choice. Just don't expect it to be free inside a busy loop.
- Build objects with the same properties in the same order.
- To clear a property on a busy object, set it to
nullorundefinedinstead of usingdelete. - If one busy helper gets many unrelated kinds of objects, and a profile says it matters, split it into smaller helpers.
- Keep the code easy to read first. Measure before and after any change.
Common misconceptions
- "The cache saves values." It saves where to find the value, behind a shape check. The value can change.
- "A polymorphic site is always bad." A few steady shapes are normal, and engines often handle them well.
- "Megamorphic means broken." It means the engine switched to a slower shared lookup for a site with many shapes. It only matters if a profile shows that site is busy and slow.
- "Shapes are part of JavaScript." They are a trick inside engines. The language only says a property is found on the object or its prototypes.
- "Engine tricks should decide how I write all my code." Clear data, tests, and real measurements matter more than rumors about speed.
Some of these words sound alike. The table below sets them side by side. Read each row as: this idea means this, and it is easy to mix up with that.
| Idea | What it means | Don't mix it up with |
|---|---|---|
| Shape | The engine's hidden record of an object's layout: the names, in order, and the slot for each. | The values inside the object. Two objects can share a shape and hold different values. |
| Inline cache | A small memory at one place in your code that says which slot to read for a shape. | A saved value. The cache saves where to find the value, not the value. |
| Polymorphic | One place in code has seen a few shapes. It checks a short list. | Megamorphic, where there are too many shapes and the site uses a slower shared table. |
| Shapes and caches | Tricks inside the engine that make property reads fast. | The JavaScript language. The language only says: look on the object, then on its prototypes. |
Practice exercises
Read the code. Type the two values it prints.
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));It prints 1, then 2. Both objects list x then y, so they share one shape. An engine may keep this site monomorphic.
How many shapes can one site keep in this lesson's model?
The model keeps 4 shapes at one site. The 5th different shape makes the site megamorphic. Real engines pick their own number.
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: no value was ever set there. Keyed caches treat arrays with holes differently from packed arrays.
Which CacheIR step checks the object's shape in the lesson's example?
GuardShape checks the object's shape. Only then does LoadFixedSlotResult read the slot.
Name one habit that helps a busy site keep seeing the same shape.
Good answers: create every expected property in the same order, avoid delete on busy objects, avoid many shapes at one busy site, or measure before changing code.
Check your understanding
Question 1 of 7What does an inline cache remember for a normal
obj.xread?Choose an answer to see the explanation.
Question 2 of 7What does this code about property order 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 this lesson's model, what happens when one place in code sees more than four shapes?
Choose an answer to see the explanation.
Question 4 of 7What does this code about an array hole print?
Read the code, then predictconst holey = []; holey[2] = "two"; console.log(0 in holey);Choose an answer to see the explanation.
Question 5 of 7A call always runs the same function. Why does that help a faster compiler?
Choose an answer to see the explanation.
Question 6 of 7Which CacheIR steps match a simple read of a property from a fixed slot?
Choose an answer to see the explanation.
Question 7 of 7Which habit best keeps object shapes steady?
Choose an answer to see the explanation.
Key takeaways
- An inline cache belongs to one place in your code. One
obj.xcan be monomorphic while another is megamorphic. - A shape lists property names and their slots. So a cached read becomes one shape check and one slot read.
- This lesson's model keeps 4 shapes per site. More than that sends the site to a slower shared table.
- Engines also cache keyed reads, writes, calls, global names, and reads through prototypes. Faster compilers use what they learn.
- Give similar objects the same properties in the same order, avoid
deletein busy code, and measure before you optimize.
In one line: the engine remembers where a property was, and checks the shape before it trusts that memory.
Next in this module, Baseline compilers shows simple compilers that start fast and skip some interpreter work, before the strongest optimizing compilers take over.