Write It Like C: What V8's Optimizing Compilers Explain About the Zen of stdlib
The Zen can read like a list of style opinions. A surprising number of the entries turn out to be describing a compiler.
JavaScript wasn't designed for numerical computing. There's one number type (a 64-bit float), no operator overloading, no control over memory layout, and a value model where every integer is, in principle, an object. And yet, on a modern V8, a tight loop over a Float64Array lands within a small constant factor of equivalent C—sometimes at basically the same speed. That's not an accident, and it's not free. It's the cumulative result of fifteen years of compiler engineering specifically aimed at recognizing, at runtime, that the dynamic-language source you wrote is in fact behaving like a statically-typed numerical kernel—and rewriting it as one.
If your first association with "V8" is horsepower and max towing capacity, the Zen of stdlib is going to read as an elegant list of design principles you're supposed to memorize. And it kind of is one—about two dozen entries. Some are about people (code is read more than it is written, be kind to your future self, tend to the garden), some are general engineering taste (don't be clever, complexity kills, simple is beautiful). Two are unmistakably about machines: write it like C, and avoid polymorphism by default, which the long form spells out as "monomorphic is best, polymorphic is not great, megamorphic is terrible." Those last three terms are counting how many different kinds of thing one spot in your code has had to handle: one, a handful, or more than the engine will keep track of.
What I didn't expect is how many of those middle entries are also about a machine. Don't be clever means something specific once you know what happens when you try to outsmart a speculative optimizer. Same with mistakes are infectious, fix them early, and stability is a feature. A lot of what looks like taste turns out to be doing real work.
I'm not a compiler engineer, I've never written a numerical kernel, and I don't write C. My job is making stdlib's conventions legible to people arriving at the codebase, and I couldn't explain half of these without knowing what V8 actually does with a loop—so I went and found out. The useful discovery, for anyone else who doesn't write C either: write it like C asks for less knowledge of C than you'd think. What it actually asks for is knowing what the optimizer is trying to prove. What follows is the map I wanted when I started, assembled from the V8 team's writing about their own pipeline and from asking stdlib maintainers why the code looks the way it does. You don't need to have gone spelunking inside V8 for it to make sense—I hadn't when I started.
The part that surprised me most: the rules have barely moved in a decade, while the machinery enforcing them has been rebuilt three or four times. That's why they're worth internalizing rather than looking up.
A note on vocabulary
This post throws around terminology—hidden classes, shapes, inline caches, elements kinds, deoptimization—and uses some of it before explaining it. Don't worry—the explanations arrive later, where the terms are doing more work. If you'd rather have a running start, two pieces cover most of it:
- Mathias Bynens — JavaScript engine fundamentals: Shapes and Inline Caches — hidden classes and inline caches, which is most of the vocabulary here; and
- Mathias Bynens — Elements kinds in V8 — how V8 tracks what is inside an array.
Feeling a little out of your depth here is fine—I definitely did.
Why a numerical JavaScript lens?
Most V8-pipeline explainers target general-purpose JS frameworks, page-load metrics, or web-app responsiveness. The optimizations they highlight absolutely apply to numerical code—function inlining, hidden-class stability, megamorphic call sites all matter here. They just aren't the whole story for a Float64Array-bound inner loop.
Keep a loop like this one in mind:
function scale( x, out ) {
for ( let i = 0; i < x.length; i++ ) {
out[ i ] = x[ i ] * 2.0;
}
}
Nothing about it is clever (which is the point). Assume x and out are Float64Arrays and that scale gets called thousands of times.
Four of the engine's questions matter much more for numerical work than they do for typical app code, and all four are about that loop. Each names something the optimizer is trying to prove, and each returns further down with the mechanism attached—so this is a map to come back to, not something to hold in your head now.
- Boxing and unboxing. Does
x[ i ]reach the multiply as a raw 64-bit float in a CPU register, or as a heap-allocated wrapper the engine reads through on every operation? Numerical code multiplies that difference by a million. - Elements kind. Is
x's backing store (the memory the elements actually live in) a contiguous block of doubles, or a tagged array of generic JS values, where every slot has to carry its own type? V8 tracks this per array, and the loop reads through that store on every iteration. - Deoptimization. The optimizer bets that
scaleonly ever seesFloat64Array. What happens on the call that breaks the bet—when V8 has to throw the specialized code away? That bet gets tested constantly, because one kernel gets reused against different inputs all day long. - Tiering. V8 has four compilers rather than one, and they trade compile time against code quality. Does
scalewant the last cycle squeezed out of peak code, or to reach peak code fast enough that a Node service or notebook session spends most of its time there? That second case is the common one for numerical code.
Most of V8's recent architectural work comes back to one or another of these four: Sparkplug, Maglev, the Turboshaft intermediate representation (IR), mutable heap numbers.
A 15-year ladder
Today's V8 has four JavaScript execution tiers. It did not start there. Each addition responds to a specific gap left by the previous tier.
Crankshaft (2010–2017)
In December 2010, Google shipped Crankshaft, V8's first speculative optimizing compiler. Crankshaft was layered above V8's existing baseline compiler, Full-codegen: cold code ran there, and once a function got hot, V8 promoted it to Crankshaft for re-compilation against type assumptions. Crankshaft used two intermediate representations: a high-level static-single-assignment (SSA) graph called Hydrogen, and a lower-level form called Lithium. It leaned heavily on inline-cache feedback collected by the running baseline code.
Crankshaft is the V8 that stdlib-shaped JavaScript was first written against. Its rules still apply today—monomorphic call sites, stable hidden classes, no megamorphism, no surprise types. They were never about Crankshaft specifically; they described the underlying contract between dynamic source and a speculative optimizer. One of those terms is worth unpacking now: a hidden class is V8's internal record of an object's layout—which properties it has, in what order, at what offsets. Objects built the same way share one; code that only ever sees a single hidden class is code V8 can specialize hard. That's what monomorphic means (a fuller treatment comes below).
What Crankshaft was bad at, and what eventually retired it: keeping pace with new ECMAScript features. Generators, classes, destructuring, let/const, async functions—every one was either grudgingly supported through baseline-only paths or left unoptimized. The team had to add every new feature to both compilers—and to a third C++ runtime that the bytecode-less baseline compiler interacted with. That cost was suffocating the team.
Ignition + TurboFan (2016–2017)
Ignition shipped first, in 2016, as a bytecode interpreter—V8 hadn't previously had one. Ignition was motivated by memory, not speed: a bytecode interpreter's memory footprint is dramatically smaller than baseline-compiled machine code, which mattered enormously for the mobile devices Chrome was deploying on.[1] As a side effect, Ignition gave V8 a single canonical place where every JavaScript program existed in a stable, structured form before any compiler ever looked at it. That single canonical form is what made the next three tiers possible.
TurboFan shipped next, and in May 2017 V8 v5.9 made the new pipeline default: Ignition for first execution, TurboFan for hot functions. Crankshaft was then removed in v6.1, and Full-codegen followed in v6.2—the latter release alone deleted more than 30,000 lines of code. TurboFan introduced two big ideas:
- A layered architecture with explicit phases (typing, simplification, lowering, scheduling, machine-specific instruction selection). Each supported CPU architecture needed only a few thousand lines of architecture-specific code, against more than ten thousand per chip architecture for Crankshaft.[2] The layering made it tractable to add new optimizations and new ES features.
- A "sea of nodes" intermediate representation that lets the compiler reorder operations very freely, exposing more optimization opportunities than a strictly ordered control-flow graph would.
For numerical code, TurboFan was much better than Crankshaft at getting out of the way: it could see through more closures, inline more deeply, eliminate redundant hidden-class checks, and unbox numbers more aggressively. If your code was already monomorphic and shape-stable, TurboFan generated meaningfully tighter machine code than Crankshaft did.
Sparkplug (2021)
For the next four years, V8 was a two-tier system: Ignition → TurboFan. The gap between them was real. Ignition's bytecode dispatch is fast as interpreters go but pays a per-instruction decode cost; TurboFan only kicks in when a function has been called enough times to justify its compilation expense. Functions in the middle—called often enough to matter, not often enough to optimize—were stuck paying the interpreter tax indefinitely.
Sparkplug, shipped in V8 9.1 in 2021, fills that gap. It is a non-optimizing baseline JIT that compiles directly from Ignition bytecode in a single pass, with no IR, no type specialization, and no hidden-class-based fast paths. It defers most real work to the same builtins Ignition uses; what it eliminates is the per-bytecode dispatch overhead. Leszek Swirski's framing in the Sparkplug post is what made it click for me: a CPU is itself an interpreter for machine code, and Sparkplug is a "transpiler" from Ignition's bytecode to the CPU's. The function moves from running in an emulator to running natively, but the work it does in each instruction is roughly identical.
Sparkplug's stack frames are bit-compatible with Ignition's. Debuggers, profilers, and exception handlers don't need to know Sparkplug exists. That compatibility is also what makes mid-loop tier promotion (on-stack replacement) trivial: because the layout matches, the engine can compile a Sparkplug version of a hot loop and patch the stack frame in place.
For numerical code, Sparkplug mostly matters at the boundaries—for kernels that are called occasionally, and for code paths around the hot loop that handle argument validation and dispatch. The actual hot loop, if you wrote it well, will eventually get to TurboFan or Maglev. Sparkplug is what runs the rest while the type feedback accumulates.
Maglev (2023)
Maglev, introduced in Chrome M117 in 2023, is a mid-tier optimizing compiler slotted between Sparkplug and TurboFan. It uses the same type feedback TurboFan does, performs many of the same specializations, and emits real optimized machine code. The difference: Maglev compiles roughly 10× faster than TurboFan and 10× slower than Sparkplug, with code quality calibrated accordingly.[3] Its IR is a traditional SSA-plus-CFG form—the program as an ordered graph of basic blocks (a control-flow graph, CFG) in which every value is assigned exactly once. TurboFan's sea of nodes goes the other way on ordering—operations float free of any fixed order, and the compiler works out afterward when each one happens. Fixing the order up front is what keeps Maglev's register allocation simple, and simple register allocation is most of why it compiles fast.
Maglev is the moment in the pipeline where speculative type specialization arrives. For numerical code that means two things become true simultaneously, and earlier in the warm-up curve than they used to:
- Hidden-class checks get folded. Where a site has only ever seen
Float64Arrayarguments, Maglev collapses the hidden-class guard and resolves the element-load to a direct memory offset. The same monomorphism rules that pay off at TurboFan now pay off at Maglev. - Representation selection runs. Maglev has an explicit phase that picks the in-register representation for every value: integer, float, or boxed object. A
Float64Arrayelement is unambiguously a float, so Maglev picks the right register kind immediately. A plainArrayof mixed numbers and strings forces a conservative boxed representation through the whole loop. This is the moment unboxing actually happens.
A second consequence of Maglev shows up on the way back. The fall itself is unchanged: a deoptimization returns execution to Ignition, and the invocation that broke the bet finishes in the interpreter—off Maglev and off TurboFan alike, not one tier down. What Maglev changes is the recovery. Sparkplug's code is never discarded, so the next call starts in baseline machine code rather than in the interpreter, and the optimizing tier the function reaches on the way back up is Maglev rather than TurboFan. Older talks describe the deopt cliff as full machine code → interpreter, with a long crawl back. The drop is the same height; the climb out is much shorter.
Turboshaft (2023–2025), not an execution tier
Turboshaft is the one entry on this list that isn't a tier, which is why the count above stays at four. In 2023 V8 began an internal effort to migrate TurboFan off the sea-of-nodes IR onto a more conventional CFG-based form, called Turboshaft. The reasons were prosaic and accumulated over a decade: sea of nodes is flexible but cache-unfriendly (related operations end up scattered in memory), bugs are harder to investigate, and rewrite passes had grown gnarly. The blog post describing the move shipped in March 2025; by then the entire JavaScript backend of TurboFan had migrated. Compile times roughly halved versus the sea-of-nodes era.[4]
Turboshaft is invisible at the source level—the optimizations TurboFan does are still the optimizations it does—but it shifts the engineering economics. Faster compilation means functions reach optimized code sooner, so a bigger share of any run executes in fast code rather than warming up. For long-running Node services and notebook-style scientific JS, that compounds. It also makes future optimization work cheaper, which means more of it.
Putting the ladder back together
A simplified schematic of the modern pipeline, including deopts:
Every promotion is gated on type-feedback signals collected by the tiers below. Every deoptimization returns to the interpreter, whichever tier it left. The path each function takes is its own—there is no global compile pass—and a function's currently-running tier is observable with node --trace-opt and friends.
For numerical code: given enough warmup, an inner loop you care about will end up in TurboFan/Turboshaft (or Maglev, if it's not hot enough for TurboFan). The supporting code around it—argument validation, dispatch, the body of any function called occasionally—will sit at one of the lower tiers indefinitely. The discipline you apply to the hot loop pays off most at the top of the ladder; the discipline you apply to the supporting code pays off mostly by keeping a megamorphic inline-cache site or a hidden-class change from deoptimizing the hot loop.
One boundary worth naming: V8 also compiles WebAssembly, through its own baseline and optimizing tiers. It's a separate pipeline with a separate story, and out of scope here.
The representation primitives that make this work
The compiler tiers are the layer most people think about, but the value representation they operate on is doing just as much of the work. A handful of pieces show up in every V8 numerical-performance discussion.
SMI vs. HeapNumber
V8 uses pointer tagging. A 64-bit slot whose low bit is zero is a Small Integer (SMI)—a signed integer stored directly in the slot, no allocation, no indirection. The payload is 31-bit on builds with pointer compression and 32-bit on builds without. That's worth checking rather than assuming: Chrome enables pointer compression, official Node builds don't, so on Node 2**31 - 1 is still an SMI. node -p "process.config.variables.v8_enable_pointer_compression" answers it for your build. A slot whose low bit is one is a tagged pointer to a heap object. V8 stores any number that doesn't fit in the SMI range—floats included—as a HeapNumber: a heap-allocated wrapper containing a 64-bit double, referenced from the slot by a tagged pointer.
That's why the boxed vs. unboxed distinction matters. In Ignition and Sparkplug, every floating-point arithmetic operation reads through a HeapNumber wrapper, possibly allocates a new one for the result, and adds garbage-collection pressure. In Maglev or TurboFan, once the optimizer has stable feedback that a value is always a float, it strips the wrapper: the raw 64-bit value lives in a CPU floating-point register, and arithmetic compiles down to a single floating-point instruction. A tight loop of thousands of float operations collapses to bare register arithmetic. Without unboxing, every number in your hot loop allocates.
Pointer compression (V8 8.0, 2019)
Pointer compression shrinks every tagged slot from 64 bits to 32 by allocating all V8 objects within a 4 GB region and storing offsets rather than full pointers. The "base" half is shared per isolate; the "index" half is what gets stored. The headline number: heap memory dropped ~40%.[5]
The visible effect on a numerical workload is mostly cache behavior. Halving slot sizes means each cache line holds twice as many tagged values, and arrays of objects (rare in stdlib's hot loops, common around them) become half as cache-cold. Pointer compression also constrains what the compiler can do—for instance, every dereference now requires a base+offset compute—but the cache wins generally outweigh the new instruction.
Mutable heap numbers (V8, 2025)
A subtler 2025 addition: in-place updates of HeapNumbers in script-context slots. Previously, a script-level let seed = ...; seed = next(seed); pattern would allocate a fresh HeapNumber on each reassignment, even when the optimizer could prove only a single number was being threaded through. Now the slot can own its HeapNumber and mutate the underlying double in place. The case study in the post is Math.random's state. For numerical code that uses module-scoped accumulators or threaded random-number-generator state, this eliminates a previously-invisible per-update allocation.
Hidden classes and inline caches
Every V8 object points to a hidden class that records what properties it has, in what order, at what offsets, and with what attributes. The naming is genuinely a mess, and it isn't your fault if you've been tripped up by it. One concept, and every engine picked a different word for it:
| Engine | Calls them |
|---|---|
| V8 | Map — nothing to do with the JavaScript Map builtin |
| SpiderMonkey | Shape — widely borrowed, so you'll meet it outside Firefox |
| JavaScriptCore | Structure |
| Chakra | Type |
Hidden class is the fifth name, and the one this post uses.[6] Academic papers use it—but so does V8, constantly, in its own documentation, whenever it's explaining rather than implementing. The heap doc introduces Maps as "also known as hidden classes or shapes" and heads the defining section "The Map (Hidden Class)"; V8's docs page on the subject is titled Maps (Hidden Classes) in V8. It isn't the obscure choice—it's the one term that travels across all four engines and back into V8's own prose.
Two objects with the same hidden class are bit-compatible to V8's fast paths. Hidden classes are built incrementally: each added property transitions the object to a new one, which is why { x, y } and { y, x } end up different despite having the same property names.
C2 to the other. An object that acquires its properties in a different order is a different shape for the rest of its life.If you come to this from C or C++, Benedikt Meurer's talk on types, classes, and maps offers the translation that tends to land hardest: a V8 hidden class plays roughly the role of a vtable pointer plus a field-offset table. "Keep the hidden class stable" then reads as the same discipline: don't reshape a struct at runtime.
Inline caches (ICs) are per-call-site memoizations of the hidden classes the site has seen. The hot path replaces dictionary lookups with a hidden-class check plus a load from a known offset. As long as a site only sees one hidden class (monomorphic) it stays fast. With two to four it becomes polymorphic—a chain of compare-and-load attempts. Past that threshold V8 gives up and falls back to a generic dictionary lookup (megamorphic), and the hot-loop assumption is over.
dasum and sasum turn out to be the same algorithm written twice so that neither one's inner loop ever meets a second shape.Hidden-class stability buys more than a fast IC, though. As Vyacheslav Egorov works through in What's up with monomorphism?, a monomorphic site lets the optimizer treat repeated hidden-class checks as redundant and eliminate all but the first. A polymorphic site structurally cannot get that: each variant needs its own guard.
ValidityCells are the trick that lets prototype-based code stay fast. Each prototype's hidden class carries a single-bit "still valid?" flag; ICs that depend on a clean prototype chain can collapse all their lookup checks down to a read of that bit. A mutation anywhere on a prototype chain (including, catastrophically, on Object.prototype) flips the cell, invalidating every IC that depended on it.
Anything that participates in a hot dispatch path has to be shape-stable—argument-shape validators, factory functions for option objects, anything that builds an object on the way into the kernel. The cost of polymorphism is not an extra cycle per access; it is the loss of the entire optimizer's bet on this call site.
Elements kinds
For array objects, V8 tracks a separate tag called the elements kind that describes what's in the numerically-indexed slots. Plain Array instances live on a six-cell lattice: PACKED_SMI / PACKED_DOUBLE / PACKED_ELEMENTS, plus the three HOLEY_* variants. The lattice is one-way. An array can be demoted from PACKED_SMI to PACKED_DOUBLE (by a single float write) to PACKED_ELEMENTS (by a single string or object write), or to a HOLEY_* variant (by delete, by new Array(n) without immediate fill, by writing past length). On the value-type axis the demotion is permanent for the life of that array, even if the offending value is removed—writing an integer back over the float doesn't restore PACKED_SMI. The holey axis is the one exception: Array.prototype.fill can return a holey array to packed, including one made holey by delete.[7]
Typed arrays—Float64Array, Int32Array, and friends—sit outside this lattice on their own elements kinds (FLOAT64_ELEMENTS, INT32_ELEMENTS, etc.). They cannot be demoted, cannot be made holey, and cannot have their type changed—the typed-array API enforces all three. From V8's point of view, a typed-array element is already in its raw machine-level representation on the backing buffer; reading one is a memory load, not a wrapper unwrap.
Typed arrays aren't an optimization; they're a guarantee. Every element is provably a fixed-width number, every access has a well-defined in-bounds outcome, every stored value is provably the right type.
That guarantee, rather than raw speed, is why numerical stdlib code is built on them—and the distinction matters, because the obvious version of the claim isn't true. A plain Array of integers isn't automatically slower than an Int32Array. Typed arrays even do work a plain array doesn't: every write is coerced to the element type, so 2**31 stored into an Int32Array reads back as -2147483648, and 300 stored into a Uint8ClampedArray reads back as 255.
What they buy isn't a lower floor. It's a narrower band. A plain array is fast right up until something demotes it, and then it's quietly slower for the rest of its life—and nothing in the code says so. A typed array can't be demoted, so the millionth call performs like the first. That's what write it like C is asking for; the Zen says "prefer predictable performance," not peak performance.
And one reason has nothing to do with the optimizer at all: a typed array is a view over an ArrayBuffer, which is the layout a C routine expects on the other side of a native add-on, so the data crosses without being copied.
What this means for stdlib-style code
This is where the aphorisms cash out. Each convention below protects one specific decision the machinery above makes, and once you can name the decision, the convention stops reading as a prohibition.
One dtype, one implementation. stdlib ships separate dasum and sasum—double-precision and single-precision absolute-sum kernels—instead of one polymorphic kernel that dispatches on argument type. Two stories combine here, elements kinds and ICs: passing a Float64Array and a Float32Array to the same function makes its loop body see two different elements kinds, so the IC at the indexed-access site goes polymorphic, and the optimizer's bet gets weaker on every successive call. Two separate functions, each used only with its own dtype, stay monomorphic. stdlib makes this argument in its own source, too. @stdlib/array/base/arraylike2object exists to normalize array-likes into one fixed-shape descriptor object, and its README says why in as many words: if objects are built with properties in different orders, "then those objects will have different 'hidden' classes," and a function fed enough of those shapes "will cause the function to be considered 'megamorphic'." The workaround is a helper whose only job is to hand every call site the same shape.
One of stdlib's maintainers frames the whole discipline in terms of the language it's imitating: "Just like in C you have to create every single different typed interface as a new function, we're doing the exact same thing." The proliferation of near-identical kernels isn't duplication that a cleverer abstraction would collapse. It's the point.
Hidden classes have to be stable up to and across the kernel. When a kernel takes an options object—a strided-ndarray descriptor, a BLAS-style transposition flag—every call site has to build that object the same way, with the same property names in the same order. If a caller mutates the object after construction (adds a property, deletes one), it transitions the hidden class out of whatever the kernel's IC was specialized on. stdlib's convention follows from that: every property defined at construction time, in a fixed order, never mutated afterward—and, where it matters, that construction centralized in a factory rather than left to each call site to remember.
Typed arrays do double duty. They give you (1) shape stability—Float64Array cannot become a Float32Array, cannot acquire holes, cannot become megamorphic—and (2) unboxed representation—every element is a raw double on the backing buffer, no HeapNumber wrapper. A plain Array of numbers can have the second: a PACKED_DOUBLE array stores raw doubles in a FixedDoubleArray, no wrappers. What it can't have is the first—and losing the first costs you the second. One string write demotes the array to PACKED_ELEMENTS, and every element becomes a tagged pointer again. The guarantee is what makes the representation durable.
Loops stay lean. TurboFan can optimize an enormous amount, but it cannot prove invariants the source doesn't make available to it. This is the mechanical content of be explicit—the optimizer is one of the readers whose mental model won't match yours. Three patterns follow from it:
- Loop unswitching. Where a loop body branches on a value that doesn't change inside the loop (an
option === 'conjugate-transpose'flag, say), stdlib hoists the branch out and writes two loop bodies. TurboFan has loop-invariant code motion, but it can't always prove an opaque comparison is side-effect-free, so the fix lands at authoring time instead—as a stdlib maintainer puts it, "if you need to duplicate loops, so be it." (In benchmarks it does a second job: it keeps the optimizer from eliminating the unobserved branch and silently measuring less work than you meant to.) - Scalar decomposition before entry. Where a complex scalar
alphais used throughout a loop, it gets decomposed once before entry—const re = real(alpha); const im = imag(alpha);—and the loop readsreandim. Even where TurboFan would have hoisted the calls, being explicit removes the uncertainty. - No allocation inside the loop. Calling
.get(i)on aComplex128Arrayinside a loop allocates a complex-number object every iteration, so kernels cast to aFloat64Arrayview of the same memory with@stdlib/strided/base/reinterpret-complex128and read pairs of doubles instead. It's the same memory layout a C implementation would walk with adouble*over interleaved real and imaginary parts, which is the part of write it like C that's literal rather than analogical. Athan Reines' accessor-protocol post unpacks that two-layer arrangement: logical complex-values on top, raw floats underneath.
The through-line: move anything invariant across iterations out of the loop at authoring time, rather than leaving it for the compiler to maybe hoist.
Plain arrays stay packed. Where a plain Array is unavoidable—the stdlib generic dtype, argument lists—stdlib builds it with a literal [ ] or with push rather than new Array(n) followed by index assignment, and removes indices with splice rather than delete. Once an array transitions to HOLEY_*, every read pays for a possible prototype-chain walk in case Array.prototype[i] was set. That cost is what fix them early is pointing at: on the value-type axis the lattice is one-way, so there's no later cleanup pass that undoes it, and even on the recoverable holey axis the fix has to be deliberate—fill is something you have to know to reach for, not something that happens on its own.
Prototypes don't change after instances exist. Adding a method to a constructor's prototype after instances exist invalidates every IC that watched that prototype. Adding anything to Object.prototype invalidates every prototype-walking IC in the running program. This is mistakes are infectious with a runtime mechanism behind it—one upstream mutation is paid for by every downstream caller, none of whom did anything wrong.
Verify, don't theorize. This was the part I found most reassuring, because it means none of the above has to be taken on faith. Run Node.js with --allow-natives-syntax and use %HaveSameMap(a, b) to confirm two objects share a hidden class, or %DebugPrint(arr) to read off an array's elements kind. Run with --trace-opt --trace-deopt to watch promotions and deopts scroll past, with the reason attached—wrong map is the one you'll see most, and it's worth knowing that's the string V8 prints for a hidden-class mismatch. IC state transitions are a little more work: the old --trace-ic flag no longer exists, and its replacement --log-ic writes to a file rather than to your terminal—V8's own tools/ic-processor then reads that file. For the generated machine code, --print-opt-code dumps it. For the IR, --trace-turbo plus Turbolizer.[8] The decisions that matter for a hot path are observable, which means "is this really monomorphic?" is a question you can answer rather than argue about.
Benchmark hygiene. Microbenchmarks that look reasonable can measure nothing—Egorov's Performance Through the Spyglass is the canonical demonstration, and his one-line version of it is hard to improve on: optimizers eat microbenchmarks for dinner. The optimizer is allowed to constant-propagate, dead-code-eliminate, hoist invariants out of the timing loop, and unroll. stdlib's benchmark convention defends against this by including explicit lightweight assertions—e.g., a NaN self-inequality check (if ( x !== x ) { b.fail(...) }) inside and after the timing loop. The trick exploits NaN's inequality with itself: the check is false for any ordinary value, so the fail path never runs. But the compiler still has to observe the read of x (which defeats dead-code elimination) and it can't fold the check away.
Where this seems to be heading
The rules—monomorphic call sites, stable hidden classes, typed-array storage, lean loops—have held for a decade. The infrastructure underneath has been rebuilt three or four times and presumably will be again. I'm not going to pretend to know what V8 ships next, but three things look like they're already in motion:
- Float16 and
Float16Arrayarrived in browsers recently. The numerical case for half precision is mostly machine-learning-shaped (model weights, intermediate activations) but that case will increasingly intersect numerical-JS code living next to ML pipelines. stdlib has already moved:float16is a working dtype with a realFloat16Arraybehind it, and it participates in the promotion and safe-cast tables like any other.complex32is the genuinely forward-looking one—it's named in@stdlib/ndarray/dtypesand in the dtype-kind taxonomy, but noComplex32Arrayexists yet to back it and its addition is slated as future work. - The Maglev → Turboshaft path keeps compressing the warmup curve. A hot loop reaches optimized machine code earlier on each successive V8 release, and where parts of a call graph used to sit in Ignition, never quite crossing the TurboFan threshold, they now land in Maglev instead. That makes occasional-path code matter slightly more than it used to: polymorphism in a moderately-called function is now something the optimizer has to account for, where before it wasn't looking.
- Mutable heap numbers and similar fine-grained representation tricks are the genre of optimization V8 is shipping right now. These are mostly invisible from the source (good code keeps getting faster) but they reward the same disciplines that are already best practice: fewer abstraction layers between the source's intent and a single number in a single register.
Most of this is invisible if the kernels are well-behaved. That's by design. The optimizer's job is to take code that describes a numerical loop dynamically and execute it as if it were a statically-typed one. The shorter the gap between those two, the easier its job.
Which is where I ended up on the aphorisms. Write it like C isn't nostalgia for a language most of stdlib's JavaScript never touches, and don't be clever isn't a general plea for humility. They both describe the shape a speculative optimizer is looking for, written in the imperative because that's the form a convention takes. Fifteen years of compiler engineering went into recognizing that shape at runtime. The list is short because the shape is simple—which is also, I think, what simple is beautiful is doing at the end of the Zen of stdlib.
Further reading
V8 team primary sources
- A New Crankshaft for V8 — Chromium Blog, December 2010
- Firing up the Ignition interpreter — V8 blog, 2016
- Launching Ignition and TurboFan — V8 blog, May 2017
- V8 release v6.1 — V8 blog (Crankshaft removed)
- V8 release v6.2 — V8 blog (baseline compiler removed)
- Digging into the TurboFan JIT — V8 blog
- TurboFan docs — V8 docs
- Maps (Hidden Classes) in V8 — V8 docs
- Objects and Maps in V8 — V8 in-repo docs
- Hidden Classes and Inline Caches in V8 — V8 in-repo docs
- Pointer Compression in V8 — V8 blog (V8 8.0)
- Sparkplug—a non-optimizing JavaScript compiler — V8 blog, 2021 (V8 9.1)
- Maglev: V8's Fastest Optimizing JIT — V8 blog, 2023 (Chrome M117)
- Land ahoy: leaving the Sea of Nodes — V8 blog, March 2025 (Turboshaft)
- Turbocharging V8 with mutable heap numbers — V8 blog, February 2025
- Fast properties in V8 — V8 blog
- Elements kinds in V8 — Mathias Bynens, V8 blog
- Celebrating 10 years of V8 — V8 blog
Talks and write-ups by V8 team members and adjacent practitioners
- Mathias Bynens — JavaScript engine fundamentals: Shapes and Inline Caches
- Mathias Bynens — V8 internals for JavaScript developers (JSConf EU)
- Benedikt Meurer — JavaScript engines: a tale of types, classes, and maps (JSCamp Barcelona, 2018)
- Franziska Hinkelmann — JavaScript engines—how do they even? (JSConf EU)
- Franziska Hinkelmann — Performance Profiling for V8 (Script'17)
- Vyacheslav Egorov — JavaScript Performance Through the Spyglass (slides, GOTO Amsterdam, 2016)
- Vyacheslav Egorov — Explaining JavaScript VMs in JavaScript—Inline Caches — 2012; the explanation V8's own Fast properties in V8 sends readers to for inline caches
- Vyacheslav Egorov — What's up with monomorphism?
- Bartek Szopka — Everything you never wanted to know about JavaScript numbers (JSConf EU 2013)
- Ishtmeet Singh — V8 JavaScript Engine in Node.js: Architecture, Tiers, Shapes, and Deoptimization — The NodeBook, September 2025
stdlib and stdlib-adjacent
stdlib-js/stdlib— the codebase@stdlib/array/dtypes— dtype registry; the "C type system at the API level"@stdlib/blas/base/dasumand@stdlib/blas/base/sasum— the canonical "one dtype, one implementation" pair@stdlib/blas/base/zaxpy(ndarray.js) — the reference implementation of the lean-inner-loop pattern withreinterpret-complex128- Athan Reines — The Zen of stdlib — blog.stdlib.io; goes into considerably more detail than the contributing-docs version
- Athan Reines — Introducing the Accessor Protocol for Array-Like Objects — blog.stdlib.io
stdlib is an open source software project dedicated to providing a comprehensive suite of robust, high-performance libraries to accelerate your project's development and give you peace of mind knowing that you're depending on expertly crafted, high-quality software.
If you've enjoyed this post, give us a star 🌟 on GitHub and consider supporting the project. Your contributions and continued support help ensure the project's long-term success and are greatly appreciated!
Acknowledgments
This work was supported in part by the National Science Foundation under Award No. 2449410.
Disclaimer: Any opinions, findings and conclusions or recommendations expressed in this material are those of the author(s) and do not necessarily reflect the views of the National Science Foundation.
V8's Ignition announcement gives both figures: baseline-compiled machine code was occupying roughly a third of Chrome's JavaScript heap, and Ignition's bytecode is 25–50% the size of the equivalent baseline machine code. ↩︎
The launch post puts Crankshaft at "more than ten thousand lines of code per chip architecture," against a few thousand for TurboFan's architecture-specific layer. ↩︎
Both ratios are the V8 team's own: Maglev compiles roughly an order of magnitude faster than TurboFan and an order of magnitude slower than Sparkplug. ↩︎
The Turboshaft write-up reports compile time "divided by 2" versus the sea-of-nodes pipeline. ↩︎
The ~40% heap-memory reduction is reported in V8's pointer-compression write-up. ↩︎
The engine-by-engine mapping is Mathias Bynens and Benedikt Meurer's, from JavaScript engine fundamentals: Shapes and Inline Caches (14 June 2018). Their list adds the collision warnings the table compresses away: Hidden Classes is confusing with respect to JavaScript classes, Maps with respect to JavaScript
Maps, and Types with respect totypeof. ↩︎V8's elements-kinds post carries an inline amendment, "Update @ 2025-02-28: There is now an exception to this for
Array.prototype.fillspecifically," and gives no further detail. Testing on Node v24.19.0 puts the exception slightly wider than the note implies:fillrestores packed status fromdelete-induced holes as well. ↩︎Singh's NodeBook has flag tables—informational, behavioral, and how to pass them—which are a far better starting point than the raw
--v8-optionsdump. ↩︎