Monomorphic vs Polymorphic JavaScript Performance
Monomorphic and polymorphic function dispatches represent how modern JavaScript engines handle operations and property accesses based on the structural shapes of objects. Because JavaScript is dynamically typed, just-in-time (JIT) compilers use mechanisms like hidden classes and inline caching to optimize function calls. A monomorphic call site, which repeatedly encounters the exact same object shape, allows the engine to generate optimized, direct machine code, whereas polymorphic and megamorphic call sites introduce branch checks and fallback lookups that significantly degrade execution speed.
Hidden Classes and Inline Caching
To understand dispatch performance, you must understand how JavaScript engines like V8, SpiderMonkey, and JavaScriptCore optimize property lookups:
- Hidden Classes (Shapes): JavaScript objects do not have fixed types. At runtime, the engine generates internal representations—often called “shapes” or “hidden classes”—to track the properties and layout of each object. Objects with identical properties initialized in the same order share the same shape.
- Inline Caching (IC): When a function accesses an
object’s property (e.g.,
obj.x), the engine caches the object’s shape and the memory offset of that property directly at the call site. On subsequent runs, if the shape matches the cached shape, the engine skips the dynamic lookup.
The Dispatch States
Inline caches operate in three primary states, transitioning from fastest to slowest:
1. Monomorphic Dispatch
A call site is monomorphic when it only ever encounters objects of a single shape.
- Mechanism: The engine generates a single check: verify the shape, then read the property directly from the known memory offset.
- Performance: Maximum performance. Monomorphic call sites can often be completely inlined by the optimizing compiler (e.g., V8’s TurboFan), removing function call overhead entirely and enabling further compiler optimizations.
function getX(point) {
return point.x;
}
// Monomorphic: Every object passed has the same shape {x, y}
getX({ x: 1, y: 2 });
getX({ x: 3, y: 4 });2. Polymorphic Dispatch
A call site becomes polymorphic when it encounters a small number of distinct shapes—typically between two and four.
- Mechanism: The engine updates the inline cache to
store a small list of known shapes and their corresponding offsets. It
evaluates these linearly (like a small
switchstatement) to find a match. - Performance: Moderate overhead. The CPU must evaluate conditional branches for each shape check. Inlining is much harder or restricted, which limits downstream optimizations.
// Polymorphic: The function handles two different shapes {x, y} and {x, y, z}
getX({ x: 1, y: 2 });
getX({ x: 3, y: 4, z: 5 });3. Megamorphic Dispatch
When a call site encounters more than four or five different shapes, the inline cache overflows into a megamorphic state.
- Mechanism: The engine abandons the linear cache list and falls back to a global stub or a hash-table lookup.
- Performance: Lowest performance. The JIT compiler cannot inline the operation, and every property access requires an indirect hash lookup, leading to significant execution overhead in tight loops.
// Megamorphic: Passing many distinct object shapes to the same call site
getX({ x: 1, a: 1 });
getX({ x: 2, b: 2 });
getX({ x: 3, c: 3 });
getX({ x: 4, d: 4 });
getX({ x: 5, e: 5 });Performance Comparison
| Dispatch Type | Shapes Handled | Lookup Strategy | JIT Inlining Viability | Relative Speed |
|---|---|---|---|---|
| Monomorphic | 1 | Direct memory offset | Highly Likely | Fastest |
| Polymorphic | 2 – 4 | Linear branch checks | Limited | Moderate |
| Megamorphic | 5+ | Global cache / Hash table | None | Slowest |
In high-performance scenarios, monomorphic dispatch can be multiple times faster than polymorphic dispatch and orders of magnitude faster than megamorphic lookups, primarily because monomorphic code enables aggressive function inlining and dead-code elimination.
How to Keep Code Monomorphic
- Initialize Properties Consistently: Always initialize properties in the exact same order within constructors or factory functions to ensure they share the same internal shape.
- Avoid Deleting Properties: Using
delete obj.propmutates the shape into a generic dictionary mode, immediately hurting cache efficiency. Assignnullorundefinedinstead. - Keep Function Signatures Homogeneous: Avoid passing numbers, strings, and custom objects interchangeably to the same utility function in performance-critical hot paths.