JavaScript Inline Caching: A Complete Tutorial

Master inline caching in JavaScript engines. Covers monomorphic, polymorphic, and megamorphic inline caches, how V8 caches property access and function calls, cache state transitions, and coding patterns that keep inline caches fast.

JavaScriptadvanced
17 min read

Inline caching (IC) is V8's primary optimization for property access and function calls. Instead of looking up property locations from scratch every time, V8 remembers where it found a property last time and checks that location first. This transforms hash-table lookups into direct memory reads.

For hidden classes that inline caches depend on, see V8 Hidden Classes in JavaScript: Full Tutorial.

How Inline Caches Work

Every property access site in your code has an associated inline cache. The first time V8 accesses a property, it records the object's hidden class and the property's memory offset. Subsequent accesses check if the hidden class matches and, if so, read the offset directly.

javascriptjavascript
// Consider this property access:
function getX(point) {
  return point.x;  // <-- This is a property access site with an IC
}
 
// FIRST CALL: IC is uninitialized
getX({ x: 1, y: 2 });
// V8 does a full property lookup:
// 1. Get hidden class of point -> Map{x@0, y@8}
// 2. Search Map for property "x" -> found at offset 0
// 3. Read value at offset 0 -> 1
// 4. CACHE: Store (Map{x@0, y@8}, offset=0) in the IC
 
// SECOND CALL: IC hit (monomorphic)
getX({ x: 3, y: 4 });
// V8 uses the cached information:
// 1. Get hidden class of point -> Map{x@0, y@8}
// 2. CHECK: Does it match cached Map? YES
// 3. Read value at cached offset 0 -> 3
// SKIP the property name lookup entirely
 
// This is ~10x faster than a full lookup
 
// Each IC has a state:
// UNINITIALIZED -> MONOMORPHIC -> POLYMORPHIC -> MEGAMORPHIC
//                                                    |
//                                                    v
//                                                 GENERIC
// (optimal)                                      (slowest)

Monomorphic Inline Caches

javascriptjavascript
// MONOMORPHIC: The IC has seen exactly ONE hidden class
// This is the fastest state
 
// All objects created the same way share a hidden class
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}
 
function distance(p) {
  // Two IC sites: p.x and p.y
  return Math.sqrt(p.x * p.x + p.y * p.y);
}
 
// Every call uses objects with the same hidden class
const points = [];
for (let i = 0; i < 10000; i++) {
  points.push(new Point(i, i * 2));
}
 
// All of these hits are monomorphic IC hits
points.forEach(distance);
// p.x IC: always Map{x@0, y@8} -> offset 0
// p.y IC: always Map{x@0, y@8} -> offset 8
 
// MONOMORPHIC FUNCTION CALLS
function process(handler) {
  handler();  // IC caches the function reference
}
 
function doWork() { /* work */ }
 
// If always called with the same function, the call IC is monomorphic
for (let i = 0; i < 10000; i++) {
  process(doWork);  // Monomorphic: always the same function
}
 
// V8 may even inline doWork into process when it is monomorphic
// Eliminating the function call overhead entirely
 
// MONOMORPHIC PROPERTY STORES
function setName(obj, name) {
  obj.name = name;  // Store IC: caches where to write
}
 
// Consistent shapes keep the store IC monomorphic
const users = [
  { name: "", age: 0 },
  { name: "", age: 0 },
  { name: "", age: 0 },
];
users.forEach((u) => setName(u, "Alice")); // Monomorphic store IC

Polymorphic Inline Caches

javascriptjavascript
// POLYMORPHIC: The IC has seen 2-4 different hidden classes
// V8 creates a small lookup table of (Map, offset) pairs
 
function getName(obj) {
  return obj.name;  // This IC site
}
 
// Two different shapes
getName({ name: "Alice", age: 30 });    // Shape 1: {name@0, age@8}
getName({ name: "Bob", role: "admin" }); // Shape 2: {name@0, role@8}
 
// The IC is now polymorphic with two entries:
// IC table:
//   Map1{name@0, age@8}  -> offset 0
//   Map2{name@0, role@8} -> offset 0
//
// On each access, V8 checks both maps in order
// Still fast (linear scan of 2-4 entries) but not as fast as monomorphic
 
// POLYMORPHIC WITH DIFFERENT OFFSETS
function getValue(obj) {
  return obj.value;
}
 
getValue({ value: 1 });                    // {value@0}
getValue({ type: "a", value: 2 });         // {type@0, value@8}
getValue({ id: 0, type: "b", value: 3 });  // {id@0, type@8, value@16}
 
// IC table for getValue:
//   Map1{value@0}                -> offset 0
//   Map2{type@0, value@8}        -> offset 8
//   Map3{id@0, type@8, value@16} -> offset 16
//
// Even though the property name is the same, different shapes
// store it at different offsets
 
// V8 handles up to 4 entries polymorphically
// After 4, it transitions to MEGAMORPHIC
 
// TurboFan handles polymorphic ICs with multi-way checks:
// Pseudocode for optimized getValue:
//   map = LoadMap(obj)
//   if (map === Map1) return Load(obj, 0)
//   if (map === Map2) return Load(obj, 8)
//   if (map === Map3) return Load(obj, 16)
//   // fallback to generic lookup

Megamorphic Inline Caches

javascriptjavascript
// MEGAMORPHIC: The IC has seen 5+ different hidden classes
// V8 gives up per-site caching and uses a global lookup cache
 
function process(item) {
  return item.name;  // This IC site
}
 
// Each call with a differently-shaped object degrades the IC
process({ name: "a" });
process({ name: "b", x: 1 });
process({ name: "c", x: 1, y: 2 });
process({ name: "d", x: 1, y: 2, z: 3 });
process({ name: "e", x: 1, y: 2, z: 3, w: 4 });
// IC is now MEGAMORPHIC after 5 different shapes
 
// Megamorphic IC behavior:
// - Uses a global "stub cache" (MegamorphicLoadIC)
// - Does a hash table lookup on every access
// - ~10x slower than monomorphic
// - Cannot be inlined by TurboFan
// - Stays megamorphic forever (does not recover)
 
// DIAGNOSING MEGAMORPHIC ICS
// Node.js: node --trace-ic script.js
// Output: LoadIC at position 42: [megamorphic] <name>
 
// COMMON CAUSES OF MEGAMORPHIC ICS
 
// 1. Processing heterogeneous data
function displayItem(item) {
  // Each item type has a different shape
  return `${item.name}: ${item.value}`;
}
 
displayItem({ name: "Product", value: 29.99, sku: "A1" });
displayItem({ name: "Service", value: 99, duration: "1h" });
displayItem({ name: "Bundle", value: 149, items: [] });
displayItem({ name: "Digital", value: 9.99, format: "pdf" });
displayItem({ name: "Physical", value: 39, weight: 2.5 });
// 5 shapes -> megamorphic
 
// FIX: Normalize to a common shape
function normalizeItem(item) {
  return {
    name: item.name,
    value: item.value,
    type: item.type || "unknown",
    metadata: {}, // Gather extra fields here
  };
}
 
const normalized = items.map(normalizeItem);
normalized.forEach(displayItem); // All same shape -> monomorphic
 
// 2. Mixing class instances with plain objects
class User { constructor(n) { this.name = n; this.type = "user"; } }
class Admin { constructor(n) { this.name = n; this.type = "admin"; } }
 
function greet(person) { return person.name; }
greet(new User("A"));
greet(new Admin("B"));
greet({ name: "C", type: "guest" });
greet({ name: "D" });
greet({ name: "E", id: 1 });
// Mix of constructors and object literals -> megamorphic

IC Performance Comparison

javascriptjavascript
// Benchmarking IC states
function benchmarkIC() {
  const iterations = 10_000_000;
 
  // MONOMORPHIC: All same shape
  const monoObjects = [];
  for (let i = 0; i < 100; i++) {
    monoObjects.push({ value: i, type: "a" });
  }
 
  function readMono(obj) { return obj.value; }
 
  console.time("Monomorphic");
  for (let i = 0; i < iterations; i++) {
    readMono(monoObjects[i % 100]);
  }
  console.timeEnd("Monomorphic");
  // ~15ms
 
  // POLYMORPHIC: 3 shapes
  const polyObjects = [];
  for (let i = 0; i < 100; i++) {
    const mod = i % 3;
    if (mod === 0) polyObjects.push({ value: i, a: 1 });
    else if (mod === 1) polyObjects.push({ value: i, b: 2 });
    else polyObjects.push({ value: i, c: 3 });
  }
 
  function readPoly(obj) { return obj.value; }
 
  console.time("Polymorphic (3)");
  for (let i = 0; i < iterations; i++) {
    readPoly(polyObjects[i % 100]);
  }
  console.timeEnd("Polymorphic (3)");
  // ~35ms
 
  // MEGAMORPHIC: 10+ shapes
  const megaObjects = [];
  for (let i = 0; i < 100; i++) {
    const obj = { value: i };
    obj["prop" + (i % 10)] = true; // 10 different shapes
    megaObjects.push(obj);
  }
 
  function readMega(obj) { return obj.value; }
 
  console.time("Megamorphic");
  for (let i = 0; i < iterations; i++) {
    readMega(megaObjects[i % 100]);
  }
  console.timeEnd("Megamorphic");
  // ~150ms
}
 
benchmarkIC();

Caching Function Calls

javascriptjavascript
// V8 also uses ICs for function call targets
 
// MONOMORPHIC CALL IC
class Handler {
  process(data) { return data * 2; }
}
 
const handler = new Handler();
function runHandler(h) {
  return h.process(42);  // Call IC: caches Handler.prototype.process
}
 
for (let i = 0; i < 10000; i++) {
  runHandler(handler);  // Monomorphic: always same method
}
// TurboFan can inline Handler.prototype.process into runHandler
 
// POLYMORPHIC CALL IC
class Logger { handle(d) { console.log(d); } }
class Counter { handle(d) { return d + 1; } }
 
function dispatch(obj, data) {
  return obj.handle(data);  // Call IC
}
 
const logger = new Logger();
const counter = new Counter();
 
dispatch(logger, "msg");   // IC learns Logger.prototype.handle
dispatch(counter, 42);     // IC adds Counter.prototype.handle
// Polymorphic with 2 entries - still reasonable
 
// BUILTIN FUNCTION ICS
// V8 also caches calls to built-in methods
const arr = [3, 1, 4, 1, 5];
 
function getLength(a) {
  return a.length;  // IC for .length on Array
}
 
getLength(arr);     // Caches: Array shape -> length offset
getLength("hello"); // Different shape! Now polymorphic: Array + String
// Avoid mixing array and string .length in the same call site
 
// CALL SITE SPLITTING
// V8 creates separate ICs for each call site
function processAll(items) {
  for (const item of items) {
    item.validate();  // IC site 1
    item.transform(); // IC site 2
    item.save();      // IC site 3
  }
}
// Each method call has its own IC, independent of the others
// If all items have the same shape, all three ICs are monomorphic
IC StateMax ShapesLookup SpeedTurboFan Optimization
Uninitialized0N/ANone
Monomorphic1Direct offset (fastest)Inlined, type-specialized
Polymorphic2-4Linear check (fast)Multi-way branch
Megamorphic5+Hash lookup (slow)Generic (no specialization)
Rune AI

Rune AI

Key Insights

  • Inline caches store the hidden class and property offset from the last property access at each code location: Subsequent accesses with the same hidden class skip the full lookup and read the offset directly
  • Monomorphic ICs (one shape) are 10x faster than megamorphic ICs (5+ shapes): Monomorphic access compiles to a single map check plus a direct memory load instruction
  • Polymorphic ICs handle 2-4 shapes with a linear check and remain reasonably fast: TurboFan generates multi-way type dispatch code for polymorphic property accesses
  • Megamorphic ICs are permanent for a compilation and fall back to hash-table lookups: Once an IC goes megamorphic, it cannot return to a faster state without recompilation
  • Normalizing heterogeneous objects to a common shape prevents megamorphic IC degradation: A normalization function that maps diverse objects to one consistent shape keeps all downstream ICs monomorphic
RunePowered by Rune AI

Frequently Asked Questions

Can a megamorphic IC become monomorphic again?

No. Once an IC reaches the megamorphic state, it stays there for the lifetime of that function's compiled code. V8 does not downgrade ICs. However, if the function is deoptimized and recompiled by TurboFan, the new compilation starts with fresh ICs. In practice, fixing the code to use consistent shapes and reloading the page (or restarting Node.js) is the way to recover from megamorphic ICs.

Do inline caches work for bracket notation (obj[key])?

Bracket notation with string literals (`obj["name"]`) is treated the same as dot notation (`obj.name`) and benefits from inline caching. However, bracket notation with dynamic keys (`obj[variable]`) cannot be cached when the key changes between calls. V8 may cache the hidden class but not the property offset since it depends on the key value. For dynamic key access, V8 falls back to hash table lookup.

How many polymorphic entries trigger megamorphic?

V8 transitions from polymorphic to megamorphic after seeing approximately 4-5 different hidden classes at a single IC site. The exact threshold can vary by V8 version. In TurboFan-optimized code, the compiler generates multi-way type checks for up to 4 shapes. Beyond that, it generates a generic lookup stub. The transition is permanent for that compilation.

Do inline caches affect Map and Set operations?

Map and Set use their own internal hash table lookups, not inline caches. The IC system applies to property access on regular objects and prototype method calls. However, calling methods on Map/Set instances (like `map.get(key)`) does use a call IC for resolving the `get` method on the Map prototype. Keeping Map operations consistent (always using the same Map constructor) keeps those call ICs monomorphic.

Conclusion

Inline caching is V8's most important optimization for property access performance. Monomorphic ICs provide direct offset reads at near-C-struct speed. Polymorphic ICs handle a few shapes efficiently. Megamorphic ICs fall back to slow generic lookups. Writing code with consistent object shapes keeps ICs monomorphic. For how V8 creates the hidden classes that ICs cache, see V8 Hidden Classes in JavaScript: Full Tutorial. For optimizing object creation to maintain IC stability, review Optimizing JS Object Creation for V8 Engine.