BitlystBitlyst
PostsSeriesTopicsAbout

Bitlyst

Short, diagram-first posts on JavaScript, React & Next.js. Follow via RSS or the newsletter.

© 2026 Mohsen Fallahnejad

PostsSeriesTopicsLinksAboutRSSGitHubX
Posts/javascript
Advanced6 min read

Part 2 of Under the Benchmark

A Fifth Object Shape Made the Same Loop Twice as Slow

How V8 hidden classes and inline caches work, why property reads jump in cost past four object shapes, and how to keep hot code monomorphic.

Mohsen Fallahnejad

Mohsen Fallahnejad

Nov 6, 2026Discuss

Same loop, same array length, same field. One line changes between these two runs: how many kinds of object are in the array.

code
4 shapes: 1.51 ns per read
5 shapes: 3.31 ns per read

The loop is one property read:

js
function sum(items) {
  let total = 0;
  for (let i = 0; i < items.length; i++) total += items[i].value;
  return total;
}

Picture this code running fine for a year, until a fifth product type joins the array. Nothing in sum changed, and each of the objects still has a value. Yet the read costs more than twice as much.


The wrong turn: reading it as Big O#

The natural first check is complexity. The loop is O(n) before and after, and n didn't change. So you look elsewhere: maybe the new type has a slow getter, or the array grew, or the benchmark is noisy.

None of those apply here. There's no getter, the array holds a million items in every run, and the gap between the slowest 4-shape run and the fastest 5-shape run is wide.

Big O counts how many reads happen. It says nothing about what one read costs, and in JavaScript that cost isn't fixed.


The reveal: a property read is a guess#

JavaScript objects look like hash maps, but looking up a string on every read would be slow. So V8 gives objects a hidden description of their layout, called a hidden class (V8's source calls it a Map). Objects with the same properties, added in the same order, share one. The hidden class records where value lives, so the object itself only stores values.

Order counts, and so does anything that adds a field on only some paths. You can ask V8 directly by running this with node --allow-natives-syntax:

js
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

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

Each read site in your code then keeps a small memo of the shapes it has seen, an inline cache. Next time, it checks "is this a shape I know?" and jumps straight to the slot.

That memo has a size limit, and it's the whole story behind the numbers at the top:

What one property read site remembers

  1. 1

    Read items[i].value

    This memo belongs to this line of code, not to the object

then it depends
1 shape seen
  1. ↳Monomorphic: one check, then read the slot
2 to 4 shapes seen
  1. ↳Polymorphic: a short list of checks
5 or more shapes seen
  1. ↳Megamorphic: stop remembering, use a shared lookup table
Past four shapes, the read site gives up on its own memo. The limit of four is V8's current behavior, not a language rule.

Here's the same loop across shape counts. The objects differ only by the name of one other field:

js
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);
Shapes at the read siteTime per readCompared to one shape
11.04 ns1×
21.14 ns1.1×
31.43 ns1.4×
41.51 ns1.5×
53.31 ns3.2×
163.29 ns3.2×
643.39 ns3.3×
1,0244.36 ns4.2×
4,09641.59 ns40×

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.

Two things stand out. The cost climbs gently through four shapes, then jumps at exactly five and sits flat. That's the step from checking a list to using the shared table.

Then there's the cliff at 4,096. The shared table has a fixed size, so the likely cause is shapes evicting each other until reads fall back to a slow full lookup. This benchmark doesn't isolate that, so treat the explanation as a good guess rather than a proven one.

Big O counts the reads. The hidden class decides what each read costs.

Be honest about the stakes, too. Going from one shape to five adds about 2.3 ns per read, which is roughly 2 ms per million reads. Almost no UI handler will ever notice. This matters in loops that run constantly: parsers, renderers, simulations, serialization, anything a profiler already points at.


The fix#

Make every object at a hot read site the same shape. In practice that means creating them all in one place, with every field set, in the same order:

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

// after: one shape, unused fields are explicit
function makeProduct({ title, price, pages = null, seconds = null }) {
  return { title, price, pages, seconds };
}

The same applies to the class above. Assign this.opt = opt every time rather than behind an if, and set a field to null rather than calling delete on it.

One more trap survives this fix: V8 tracks the kind of value in a field, too. Two makeProduct results compared as the same shape with whole-number prices, but not when a later one had price: 3.5. The older object keeps its old hidden class at least until V8 migrates it, so keep a hot numeric field all integers or all decimals if you can.

If the shapes really must differ, split the read site so each variant gets its own loop, and each line stays monomorphic. Either way, do this only after a profile points at the loop. A cleaner data model is a fine reason on its own, but 2 ms per million reads usually isn't.


The rule#

In hot code, keep each property read seeing as few object shapes as possible, and treat four as the line you don't want to cross.


TL;DR#

  • V8 gives objects with the same properties in the same order a shared hidden class, which tells it where each field lives.
  • Each property read in your code caches the shapes it has seen: one is monomorphic, up to four is polymorphic, and five or more is megamorphic.
  • In this benchmark, a read went from 1.04 ns at one shape to 1.51 ns at four and 3.31 ns at five, then 41.59 ns at 4,096 shapes.
  • Key order, fields added conditionally, and delete all create extra shapes or slower objects without any visible change in your code.
  • Only hot loops feel this, so create objects in one place with all fields set, and profile before contorting anything else.

Further reading: Shapes and Inline Caches by Mathias Bynens, and What's up with monomorphism? by Vyacheslav Egorov.

Filed under#javascript#v8#performance#hidden-classes#inline-cache

Series

Under the Benchmark

Same code, wildly different speed. Measured explanations of what the engine and hardware are really doing.

  1. 11,000 Cards, 1,000 Layouts, and requestAnimationFrame Didn't Help
  2. 2A Fifth Object Shape Made the Same Loop Twice as Slow · you are here
  3. 34 KB of Text Was Holding 200 MB of Memory
  4. 4Shuffling an Array Made the Same Loop Seven Times Slower
  5. 5Keeping Ten Times More Objects Made Garbage Collection Four Times Cheaper
Next part: 4 KB of Text Was Holding 200 MB of Memory →

Was this worth your time?

One tap tells me what to write more of.

Mohsen Fallahnejad

Written by

Mohsen Fallahnejad

Frontend engineer explaining JavaScript, React & Next.js internals in posts you can finish over coffee. More about me

X ↗GitHub ↗LinkedIn ↗
← Older4 KB of Text Was Holding 200 MB of MemoryNewer →Keeping Ten Times More Objects Made Garbage Collection Four Times Cheaper

Start the discussion

Questions, corrections, a better approach? I read and reply to every comment.

Read next

Shuffling an Array Made the Same Loop Seven Times Slower

How memory locality and CPU cache misses affect JavaScript: why array order changes loop speed for large arrays of objects, and when typed arrays help.

Nov 6, 20266 min read

Keeping Ten Times More Objects Made Garbage Collection Four Times Cheaper

How V8's generational garbage collector really charges you: scavenges cost per survivor, and allocation site pretenuring can make long-lived objects cheaper.

Nov 6, 20267 min read

4 KB of Text Was Holding 200 MB of Memory

Why slice() in V8 can keep a huge parent string alive, how sliced strings cause memory leaks in Node.js, and how to copy a string to release it.

Nov 6, 20265 min read

Get new posts in your inbox

No spam. Unsubscribe any time.

6 min left
Discuss👍 React
👍