Measured Results
Chrome for Testing 149.0.7827.55 (arm64 macOS) via playwright-core/CDP.
Reproduce: cd ../src && npm run all. Medians of 7 reps after 3 warm-up runs.
These are V8 implementation details on one version, not language guarantees.
1. Inline caches — 3,000,000 property reads per rep
| distinct shapes | IC state | median ms | ns/read | vs monomorphic |
|---|---|---|---|---|
| 1 | monomorphic | 5.30 | 1.767 | 1.00× |
| 2 | polymorphic | 5.30 | 1.767 | 1.00× |
| 4 | polymorphic | 6.50 | 2.167 | 1.23× |
| 8 | megamorphic | 10.40 | 3.467 | 1.96× |
| 16 | megamorphic | 10.30 | 3.433 | 1.94× |
The access site is character-identical in every row; only shape variety differs.
Note the shape of the curve. Nothing happens from 1→2. The cliff is between 4 and 8 — the polymorphic→megamorphic boundary — and past 8 it flattens: 16 shapes is no worse than 8, because the site has already fallen back to the stub cache.
Absolute penalty: 1.7 ns per read here, 2.9 ns in the perspective run (different iteration count and JIT tier).
2. Hidden classes — 400,000 objects created + read per rep
| variant | median ms | vs A |
|---|---|---|
| A — same property order | 5.60 | 1.00× |
| B — alternating property order | 6.40 | 1.14× |
C — delete o.tmp (dictionary mode) | 66.80 | 11.93× |
D — o.tmp = undefined instead | 5.50 | 0.98× |
The folklore is mostly wrong and one part is right. Property order (the part everyone repeats)
is 1.14×. delete (the part people wave away) is 11.9×, and the fix is free.
3. Array element kinds — 5,000,000 elements summed per rep, fresh page per case
| element kind | median ms | ns/elem | vs SMI |
|---|---|---|---|
PACKED_SMI — all small integers | 7.80 | 1.560 | 1.00× |
PACKED_DOUBLE — all doubles | 4.70 | 0.940 | 0.60× |
PACKED_ELEMENTS — 1% nulls | 7.70 | 1.540 | 0.99× |
HOLEY_SMI — 1% holes | 8.00 | 1.600 | 1.03× |
Spread: 1.70×.
Doubles measured faster than small integers, and I did not fully isolate why. The first
hypothesis — the accumulator overflowing SMI range and transitioning to double mid-loop — was
tested and rejected: constraining values to i % 100 keeps the sum near 2.5e8, well inside SMI
range, and the ordering did not change. The most plausible remaining explanation is that SMI
addition carries an overflow check per accumulate which double addition does not.
Recorded as unexplained. It is ~0.6 ns/element either way, which is why it did not justify further investigation — a judgement this module explicitly endorses, and an open question in the learning log rather than a claim.
4. Perspective — what these optimisations are worth
| operation | ns each |
|---|---|
| monomorphic property read | 0.9 |
| megamorphic property read | 3.8 |
createElement + textContent + appendChild | 360 |
addEventListener + removeEventListener | 130 |
style write + getBoundingClientRect (forced layout) | 4,200 |
JSON.parse of a ~200 KB API response | 396,000 |
Megamorphic penalty: 2.9 ns per read.
Property reads you must de-megamorphise to save the cost of one:
| operation | equivalent reads |
|---|---|
addEventListener + removeEventListener | 45 |
| create + append one element | 124 |
| one forced layout | 1,448 |
one JSON.parse of a 200 KB response | 136,552 |
Removing one forced layout from a render path is worth more than de-megamorphising 1,448 property accesses. One avoided 200 KB JSON parse is worth more than 136,000.
This table is the module's deliverable. Bring it to the next discussion about object shapes.
Harness failure modes — three, all producing plausible wrong answers
-
Shared IC state across cases. One page for all four element kinds meant the shared
sumaccumulated feedback across kinds: the first case measured paid JIT warm-up, later cases inherited a warm-but-polymorphic function. Reported doubles as 4× faster than SMIs. Fix: fresh page per case. -
DOM contamination between cases.
createAppendleft 5,000–20,000 children in the host element, so every subsequentgetBoundingClientRectmeasured layout over a huge subtree. The script did not produce a wrong number — it timed out, which was luckier than the alternative. Fix: fresh page per case, and reset the DOM inside the case. -
Accumulator type transition. Summing raw
iover 5M elements reaches ~1.25e13, overflowing SMI range and transitioning the accumulator to a double partway through the loop — attributing a cost to the array that belonged to the sum variable. Fix: constrain values so the accumulator stays in range. (This fix did not change the ordering, which is how it was ruled out as the cause of finding 3.)
Plus one mechanical trap worth recording: backticks inside comments in a page template literal terminate the template, twice producing syntax errors far from the real line.
Rule: every micro-benchmark needs a case whose answer you already know. If the harness cannot reproduce a result you are certain of, it cannot be trusted on one you are not.