Analysis
When this actually matters
The honest list. If your situation is not on it, the perspective table applies and you should be working on something else.
| Situation | Why the arithmetic changes | Example |
|---|---|---|
| Per-element cost × 10⁵–10⁷ | 3 ns becomes 30 ms | virtualized table over 10M cells (fe-30) |
| Per-frame budget of 16.7 ms | small constants recur 60×/s | canvas/WebGL render loop, physics |
| Hot library internals | your users' users pay it | reconciler, parser, diff, state store |
| Worker transforms | dominated by data representation | columnar processing, structured clone (fe-28) |
| Data representation decisions | one-way and expensive to reverse | array-of-objects vs object-of-arrays; Map vs object |
Everywhere else — component code, event handlers, data fetching, form logic, business rules — the effects are real and swamped by DOM, layout, network and scheduling.
The test to apply before starting: what is the operation count, what is the absolute per-op saving, and what else is on the same critical path? If you cannot answer all three, you are not ready to spend anyone's time.
Ratio vs absolute: the arithmetic that ends most debates
A proposal to "fix megamorphic access in the render path" sounds serious. Price it:
penalty 2.9 ns per read
reads per render 50,000 (generous for a component tree)
saving 0.145 ms per render
0.145 ms against a 16.7 ms frame budget: 0.9%. Now price the alternative:
one forced layout removed 4,200 ns = 0.0042 ms
...which sounds worse, until you count how many forced layouts a thrashing render path performs. At 800 elements interleaving reads and writes, fe-01 measured 86 ms. The shape refactor buys 0.145 ms; removing the thrash buys 86 ms. Same engineer, same week, 590× the return.
This arithmetic is reusable. Convert to absolutes, multiply by real counts, compare against alternatives on the same path. Most micro-optimisation proposals do not survive it — including ones you will be tempted by.
delete is the exception worth acting on
11.9×, and the fix is free. It is worth a lint rule where objects are hot, because it is the rare case where the folklore, the measurement, and the cost of compliance all align.
// dictionary mode: 11.93x
const o = { x, y, z, tmp: 0 }; delete o.tmp;
// shape preserved: 0.98x
const o = { x, y, z, tmp: 0 }; o.tmp = undefined;
// no phantom key at all — usually the right answer
const { tmp, ...rest } = o;
// dynamic keys that get deleted: this is what Map is for
const m = new Map(); m.set(k, v); m.delete(k);
The third form allocates a new object, which is the correct trade in almost all frontend code and
the wrong one in a hot loop. The fourth is the structural answer: if keys are dynamic and deleted,
a plain object is being used as a dictionary and Map is the type that was designed for it.
The polymorphic→megamorphic cliff
The IC curve is not linear, and the shape of it changes what you do:
1 shape 1.00x
2 shapes 1.00x <- free
4 shapes 1.23x
8 shapes 1.96x <- cliff crossed
16 shapes 1.94x <- flat; already in the stub cache
Two consequences:
- Going from 1 to 4 shapes is nearly free. Refactoring to make a site strictly monomorphic is usually not worth it.
- Going from 8 to 80 shapes costs nothing further. Once a site is megamorphic, "reducing" shape variety buys nothing unless you get back under the threshold — which is rarely achievable in code that legitimately handles heterogeneous data.
So the only actionable version is: avoid crossing the cliff in code that is genuinely hot. Once crossed, stop optimising and go read the perspective table.
Why Map deserves rehabilitation
The folklore ("objects are faster than Maps") comes from benchmarks with static string keys, where
an object's shape is stable and its IC monomorphic. That is the case Map was never for.
| Use | Better choice | Why |
|---|---|---|
| Fixed, known keys | plain object | stable shape, monomorphic ICs |
| Dynamic keys from data | Map | no shape churn, no dictionary-mode cliff |
| Keys added and deleted | Map | delete on an object costs 11.9× |
| Non-string keys | Map | objects coerce keys to strings |
| Needs size / iteration order | Map | .size is O(1); insertion order guaranteed |
| Keyed by object lifetime | WeakMap | see fe-02 |
A plain object accumulating user IDs as keys is a shape hazard and a dictionary-mode candidate and a leak candidate. Three separate modules point at the same refactor.
What breaks at scale
- Deoptimisation loops. A function repeatedly optimised then deoptimised by an unexpected type
is far worse than never optimising. These are invisible without
--trace-deopt, which means a frontend engineer will essentially never find one — which is itself the argument for not speculating about JIT behaviour in code review. - Benchmarks that stop resembling the application. A micro-benchmark runs one shape through one site. Production runs your component with props from twelve call sites. Shape-stability results from a benchmark routinely fail to reproduce in the app, and the benchmark is the thing that is wrong.
- Framework internals move the ground. Which objects a framework allocates per render, and whether they are shape-stable, is a framework implementation detail that changes between minor versions. Building application-level optimisations on assumptions about it creates work that silently expires.
- Engine divergence. These are V8 behaviours. Safari (JavaScriptCore) and Firefox (SpiderMonkey) differ in IC design, element-kind handling, and tiering. Any optimisation justified by numbers from one engine needs a statement about the other two before it becomes a standard.
- The opportunity cost is the real risk. The failure mode of this module is not writing slow code; it is a senior engineer spending a sprint on shapes while a request waterfall, an unindexed list render, and a 200 KB JSON parse sit untouched on the same critical path.