open atlas
↑ Back to track
JavaScript Engine internals JSE · 08 · 02

Monomorphism discipline

The actionable checklist that ties the track together: build objects with fields in a fixed order, never delete, keep arrays one element kind, pass consistently-shaped arguments to hot functions, keep numbers in Smi range, and avoid megamorphic dispatch

JSE Senior ◷ 14 min
Level
FoundationsJuniorMiddleSenior

Every deopt loop, megamorphic call site, and dictionary-mode object you traced in the last lesson has the same root: the engine made a bet about a shape or a type, and your code changed it under the optimised code’s feet. This lesson is the inverse — the small set of habits that keep those bets winning. None of them is a micro-optimisation. They are how you write JavaScript that stays on V8’s fast paths by default.

One principle, six rules

When you know where deopts and megamorphic sites come from, the next question is: what habits prevent them in the first place? The answer is surprisingly compact.

V8’s optimised code is speculative: TurboFan compiles a function assuming the shapes and types it has seen so far keep arriving. Break that assumption and you pay — a deopt, a polymorphic IC, a dictionary-mode fall, a Smi-overflow bail-out. The discipline below is just “do not surprise the optimiser”. Each rule maps to a mechanism you have already met in this track (mono/poly/mega ICs, shape transitions, element kinds, Smi vs HeapNumber); here they become a checklist.

Rule 1 — construct with all fields in a fixed order

Build objects so every instance walks the same hidden-class transition path: declare all fields in a constructor or factory, in a constant order, with no conditional additions. Two objects with the same fields added in different orders are different shapes; a field added only sometimes creates a second shape. The fix for absent data is a default value, not omission:

// DON'T — conditional + late fields → many shapes
function makeRow(r) {
  const o = {};
  o.id = r.id;
  if (r.name) o.name = r.name;     // shape A vs shape B
  o.score = r.score;
  if (r.flagged) o.flag = true;    // shape C, D...
  return o;
}

// DO — one shape, every time
function makeRow(r) {
  return {
    id: r.id,
    name: r.name ?? null,
    score: r.score,
    flag: r.flagged === true,
  };
}

This single rule prevents the most common production regression: a hot access site going megamorphic because objects were built in input-key order. (See mono/poly/mega ICs for what megamorphic costs.)

Rule 2 — never delete; assign null

delete obj.x cannot just clear a slot — it forces the object out of its fixed-offset layout into dictionary mode, a hashmap-backed representation where every property access takes the slow generic path (~50–100 cycles vs 1) and the object never recovers. The memory you “save” is tiny; the slowdown is permanent. Set the field to null instead and the shape stays intact.

Rule 3 — keep an array one element kind

V8 tracks each array’s element kind and only widens it (one-way): PACKED_SMI_ELEMENTS (fastest) → PACKED_DOUBLE_ELEMENTSPACKED_ELEMENTS (any type) → the HOLEY_* variants (add a hole-check on every access). Mixing a 1.5 into an array of integers widens it forever; writing past length or new Array(n) makes it HOLEY. Habits that keep it tight:

  • Prefer [] + push over new Array(n) or index-beyond-length (both create HOLEY).
  • Prefer Array.from({length}, fn) over new Array(n).fill() when you need a sized PACKED array.
  • Do not store mixed types (Smi, double, object) in one hot array; split by kind or use a typed array.

Rule 4 — feed hot functions consistently-shaped arguments

A function’s inline caches key off the shapes and types of its arguments. Call render(node) with one node shape and the ICs stay monomorphic; call it with five different shapes and they go megamorphic, and TurboFan cannot specialise. If a hot function must accept variants, split it into shape-specific functions or normalise inputs to one shape before the hot loop.

Rule 5 — keep numbers in Smi range (or go typed)

A small integer (Smi, ±2^31 on 64-bit) lives inline in a pointer slot with arithmetic in one instruction; cross that boundary even once and the value becomes a heap-allocated HeapNumber, and a TurboFan loop that assumed Smi deopts with reason: Smi. Bound counters and ids inside the Smi range, or commit to doubles from the start with a Float64Array so the optimiser specialises on double and never has to bail. (See Smis and doubles for the Smi/HeapNumber boundary.)

Rule 6 — keep dispatch stable

A call site that invokes the same function (or one of a few stable functions) optimises well. A site that calls many different functions — a giant switch dispatching to dozens of handlers, or a method called polymorphically across many subclasses — goes megamorphic and loses inlining. Prefer a small, stable set of callees on hot paths; a Map of typed handlers built once beats rebuilding closures per call.

The cost of breaking each rule
Monomorphic property load
~1 cycle
Megamorphic generic load
~10-50x slower
Dictionary-mode access (after delete)
~50-100 cycles, permanent
Smi arithmetic
1 instruction, no allocation
HeapNumber arithmetic
alloc + dereference + deopt risk
HOLEY array access
+1 hole-check per access
Quiz

You want to remove a field from a hot, long-lived object. What do you do?

Quiz

A hot numeric array starts as PACKED_SMI. Which single operation degrades it the most durably?

Order the steps

Order these object habits from most monomorphism-friendly to most destructive.

  1. 1 Object literal with all fields in a fixed order
  2. 2 Class constructor that assigns every field unconditionally
  3. 3 Adding some fields conditionally after creation
  4. 4 delete on a property (forces permanent dictionary mode)
More practice

A practical audit: take one hot factory or constructor and run a build of it under --trace-ic (or model it like the sandbox in this unit — count distinct Object.keys signatures). If a fixed-schema input yields more than one shape signature, a conditional or late field is leaking a second hidden class. Fix it by initialising every field unconditionally, then re-trace and confirm the access site reports monomorphic.

Recall before you leave
  1. 01
    Why does initialising every field unconditionally (even to null) beat adding fields conditionally?
  2. 02
    Walk through the element-kind ladder and the habits that keep an array fast.
  3. 03
    What makes a call site megamorphic, and how do you keep dispatch stable?
Recap

Monomorphism discipline is the inverse of every failure in this track: instead of surprising V8’s speculative optimiser, you keep its bets winning. Six rules, each tied to a mechanism. Construct objects with all fields in a fixed order via a class or factory so every instance shares one hidden class (the fix for absent data is null, not omission). Never delete — it forces permanent dictionary mode; assign null. Keep each hot array a single element kind (prefer [] + push and Array.from over new Array(n); never mix Smi/double/object or create holes). Feed hot functions consistently-shaped arguments so their ICs stay monomorphic. Keep numeric values inside the Smi range, or commit to doubles via a typed array, to avoid HeapNumber deopts. And keep dispatch to a small stable set of callees so call sites do not go megamorphic. None of these is a micro-trick; together they are how you write JavaScript that stays on the fast path by default. Now when you reach for delete, see a conditional field, or mix types in a hot array, you will recognise it as a bet against the optimiser — and you will know the cheap fix.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.