DEV Community

Cover image for Hidden Classes in V8: Why Object Shape Changes Speed
Mohsen Fallahnejad
Mohsen Fallahnejad

Posted on Edited on Originally published at bitlyst.blog

Hidden Classes in V8: Why Object Shape Changes Speed

Same loop, same million objects, same field. The only change is how many kinds of object are in the array:

4 shapes: 1.51 ns per read
5 shapes: 3.31 ns per read
Enter fullscreen mode Exit fullscreen mode

The loop is a single property read:

for (let i = 0; i < items.length; i++) total += items[i].value;
Enter fullscreen mode Exit fullscreen mode

Big O is O(n) in both runs. One extra object shape still made each read more than twice as expensive.


The mental model: seating charts

Think of every object as having a seating chart: a card that says "title sits in seat 1, value in seat 2." Objects with the same properties, added in the same order, share one chart. V8 calls the chart a hidden class.

Each line of code that reads items[i].value remembers the charts it has seen, so next time it can go straight to the right seat. That memory is the inline cache, and it holds up to four charts.

One chart: instant. Up to four: a quick check. Five or more: the line gives up remembering.


How it works

Plain JavaScript objects look like dictionaries, but looking up a string on every read would be slow. With a seating chart, V8 only has to check "is this a chart I know?" and then read a fixed seat.

You can see the charts yourself with node --allow-natives-syntax:

const a = { x: 1, y: 2 }, b = { x: 3, y: 4 }, c = { y: 2, x: 1 };
%HaveSameMap(a, b); // true
%HaveSameMap(a, c); // false  ← same keys, different order
Enter fullscreen mode Exit fullscreen mode

A read site moves through three states depending on how many charts it has seen:

What one property read remembers

1. Read items[i].value  — This memory belongs to the line of code, not to the object
  ├─ 1 shape → Monomorphic: one check, then read
  ├─ 2 to 4 shapes → Polymorphic: a short list of checks
  └─ 5+ shapes → Megamorphic: fall back to a shared lookup table
Enter fullscreen mode Exit fullscreen mode

The limit of four is V8's current behavior, not a language rule.

Shapes at the read site Time per read
1 1.04 ns
4 1.51 ns
5 3.31 ns
64 3.39 ns

Node 26.13 (V8 14.6), Apple M3 Pro, median of 7 fresh processes.

The cost climbs slowly up to four shapes, jumps at exactly five, then stays flat. That jump is the move from "my own short list" to "the shared table."

You can get new shapes without meaning to:

  • Key order: { x, y } and { y, x } are different charts.
  • Conditional fields: if (opt) this.opt = opt gives one class two shapes.
  • delete: removing a property can turn the object into a slower dictionary.

In practice

Create objects in one place, with every field set, always in the same order:

// before: different shapes depending on the type
const book = { title, price, pages };
const song = { title, price, seconds };

// after: one shape, unused fields set to null
function makeProduct({ title, price, pages = null, seconds = null }) {
  return { title, price, pages, seconds };
}
Enter fullscreen mode Exit fullscreen mode

Set a field to null instead of using delete, and assign optional fields every time instead of behind an if.

Honest stakes: going from one shape to five costs about 2 ms per million reads. A click handler will never notice. Parsers, renderers, simulations and serializers can. Change your data model for this only after a profiler points at the loop.


Say it in an interview

"V8 gives objects a hidden class that describes their layout. Objects with the same properties in the same order share one. Each property access caches the hidden classes it has seen. With one it's monomorphic and fast, with up to four it's polymorphic, and with five or more it's megamorphic and falls back to a slower lookup. To keep hot code fast, create objects with the same fields in the same order and avoid delete."


Cheat sheet

Remember Detail
Hidden class An object's seating chart: which property sits where
Same chart Same properties, added in the same order
Inline cache Each read site remembers up to 4 charts
Monomorphic / polymorphic / megamorphic 1 shape / 2–4 shapes / 5+ shapes
Shape breakers Different key order, conditional fields, delete

TL;DR

  • Objects with the same properties in the same order share a hidden class.
  • Each property read caches up to four hidden classes. A fifth makes it fall back to a slower shared lookup.
  • Here a read cost 1.04 ns with one shape, 1.51 ns with four and 3.31 ns with five.
  • Only hot loops feel it, so create objects in one place with all fields set, and profile first.

Going deeper

Advanced and optional. Everything above is enough to explain this in an interview. This part is for the curious: full numbers, edge cases and what's still unproven.

Full measurements

The objects differ only by the name of one other field, so each maker produces a new shape:

const makers = Array.from({ length: n }, (_, i) =>
  new Function("v", `return { k${i}: 0, value: v };`));

const items = new Array(1_000_000);
for (let i = 0; i < items.length; i++) items[i] = makers[i % n](i & 7);
Enter fullscreen mode Exit fullscreen mode
Shapes at the read site Time per read Compared to one shape
1 1.04 ns 1×
2 1.14 ns 1.1×
3 1.43 ns 1.4×
4 1.51 ns 1.5×
5 3.31 ns 3.2×
16 3.29 ns 3.2×
64 3.39 ns 3.3×
1,024 4.36 ns 4.2×
4,096 41.59 ns 40×

Node 26.13 (V8 14.6), Apple M3 Pro. Each row is the median of 7 fresh processes, 60 passes over a million objects after a warm-up.

The cliff at 4,096 shapes

The megamorphic fallback uses a shared table of fixed size. The likely cause of the cliff is shapes pushing each other out of that table until reads fall back to a slow full lookup. This benchmark doesn't isolate that, so treat it as a good guess, not a proven explanation.

More ways to get extra shapes

class P {
  constructor(x, opt) {
    this.x = x;
    if (opt) this.opt = opt;
    this.y = 1;
  }
}
%HaveSameMap(new P(1), new P(1, 2)); // false  ← one class, two shapes

const d = { x: 1, y: 2 };
delete d.y;
%HasFastProperties(d); // false  ← now a slower dictionary-style object
Enter fullscreen mode Exit fullscreen mode

V8 tracks value kinds too

Two makeProduct results compared as the same shape when both had whole-number prices, but not after a later one had price: 3.5. V8 records how a field is stored (small integer or decimal), and the older object keeps its old hidden class at least until V8 migrates it. In hot code, keep a numeric field all integers or all decimals if you can.

When shapes must differ

Split the read site: give each variant its own loop, so each line of code sees a single shape and stays monomorphic.

Further reading


Originally published on Bitlyst.

Top comments (0)