A Tour of V8's Internals: The Forty Tricks That Make JavaScript Fast
Forty source-grounded tours of the V8 engine: hidden classes, five kinds of string, three garbage collectors, four compilation tiers, and the small tricks that make JavaScript run at native speed.
TL;DR: V8 is a JavaScript engine that pretends to be a C++ program. That pretence is the whole design: objects have hidden classes so that property access is a pointer chase, strings have five representations so that concatenation is free, the heap has three collectors so that each generation can be collected the cheap way, and there are four tiers of code so that a function can be wrong for a while and still be fast. This is a tour of forty of those tricks, and where in the source tree each one lives.
node --version prints a version number. What it does not print is the
several hundred decisions that make for (let i = 0; i < n; i++) sum += arr[i] run at two nanoseconds per iteration. This is a tour of forty of
those decisions - the ones that are worth knowing about, grouped by the part
of the engine they live in.
1. The object model: shapes, not classes
JavaScript has no classes at the memory level. What it has is shapes: every
heap object points at a Map that says what it contains and where.
- How V8 Gives Every Object a Hidden Class - adding a property walks a tree of
Maps, and the order in which you add properties decides which branch you end up on. - The Prototype Chain: How V8 Builds an Object’s Class Hierarchy - a
classis two objects and aMap, and changing a prototype invalidates the code that assumed it.
The interesting part is not that Maps exist. It is that the transition
tree is shared between every object that was built the same way, that it is
kept alive by weak references so that unused shapes prune themselves, and that
the moment somebody mutates a prototype, V8 walks the tree and marks every
dependent piece of optimized code for deoptimization.
2. Strings are five kinds of string
A String in V8 is not a buffer of characters. It is a tag in three bits:
// src/objects/string.tq:54
extern enum StringRepresentationTag extends uint32 {
kSeqStringTag,
kConsStringTag,
kExternalStringTag,
kSlicedStringTag,
kThinStringTag
}- Five Kinds of String: How V8 Keeps Strings Cheap - cons strings make
a + bfree, sliced strings makes.slice(10)free, and thin strings makeString.prototype.trimfree. Somebody pays later, and the article says who. - Number to String: How V8 Prints a Double - the shortest-round-trip guarantee, Dragonbox, and the one bit of the significand that decides a tie.
The kExternalStringTag is the odd one out: it is how a String can be a
view onto memory that V8 does not own, and it is the reason detaching an
ArrayBuffer is a security event rather than a bookkeeping event.
3. The heap: three collectors and a barrier
V8 is generational, but “generational” undersells it. There is a young generation that is copied, an old generation that is marked, and a set of mechanisms that make the whole thing cheap enough that you do not notice it.
- Scavenging the Young Generation - two semi-spaces, Cheney’s algorithm, and the trick that lets the scavenger copy an object while another thread is reading it.
- The Write Barrier - the barrier records slots, not addresses, which is why the remembered set is small enough to be a bitmap.
- Concurrent Marking - the tri-colour invariant, the combined barrier, and the pause the mutator has to give the marker.
- How V8 Decides When to Run a Garbage Collection - there is no
ShouldCollectGarbage(); there is a heap growing factor computed from mutator utilisation. - Root Scanning - the roots table, the handle scopes, the conservative stack scan and its false positives.
- The V8 Sandbox - a terabyte of address space, reserved once, so that an attacker cannot turn a bug into a pointer.
- WeakRef, FinalizationRegistry and the Ephemeron Problem - why a
WeakRefcannot be a weak pointer, and what the fixpoint iteration costs.
4. Four tiers of code
A JavaScript function in V8 can be in one of four states, and the ladder between them is paid for by an interrupt budget rather than by a timer.
- Ignition - the interpreter, its 768-entry dispatch table, and why the bytecode is a register machine.
- Sparkplug - a 1:1 bytecode-to-machine-code compiler that optimises nothing and shares the interpreter’s stack frame.
- Maglev - the middle tier: a real SSA graph, cheap enough to compile that it can afford to be wrong.
- TurboFan - the optimizing tier: typer, lowering, a list scheduler, and a register allocator that has never heard of JavaScript.
- The Interrupt Budget - how the countdown in the feedback cell decides when a function deserves better code.
- Deoptimization - how V8 rebuilds an interpreter frame from a byte-coded translation table, and why the reason lives in the relocation info.
- Flushing Code - the 16-bit wall-clock age on a
SharedFunctionInfo, and the moment V8 throws away your optimized code because the heap is full. - WebAssembly and Liftoff and TurboFan - the same ladder, with different rungs.
5. What the optimizer actually does
TurboFan’s value is not that it is fast to compile. It is that it deletes work, and each deletion has a specific analysis behind it.
- Escape Analysis - an object that never escapes becomes a handful of SSA values, and the allocation disappears.
- Load Elimination - the map check from one load is reused by the next, and a field with a stable map becomes a constant.
- Loop Peeling, Unrolling and Induction Variables - proving a trip count is 1 removes a loop, and rewriting the index removes a multiply.
- Value Numbering - hash-consed nodes, so two identical expressions in the source become one node in the graph.
Every one of those deletions is guarded by a check that can fail, and every one of those checks is a deoptimization point. The optimizer’s real contract is not “this is fast” but “this is fast until it is not”.
6. The feedback loop
V8 does not know what your code does. It watches it do that, and it stores what it saw in two places: a feedback vector and a set of inline caches.
- The Feedback Vector - where V8 stores what it learned, and why the feedback belongs to a closure rather than to a function.
- Inline Caches - how a property load remembers where the property lives, and the nine states between “uninitialized” and “megamorphic”.
The pair is what makes the whole thing work: the IC is what makes the load fast now, and the feedback vector is what lets the compiler prove that the load will still be fast later.
7. Builtins are written in a DSL
A large fraction of V8’s src/builtins/ is not C++.
- Torque - the DSL in which V8 writes most of its own builtins, and how a
.tqfile becomes machine code. - The Code Stub Assembler - the portable layer underneath Torque, where V8 writes machine code for three architectures at once.
- The Microtask Queue - the per-realm ring buffer where promise reactions wait, and the checkpoint that drains it.
- Generators and Async Functions - how a function is suspended, how it is resumed, and how
awaitis a promise reaction whose handler is the generator.
8. The edges
Everything else is a specialised corner of the same machine.
- How V8 Parses JavaScript - preparse blobs, lazy compilation, and the cache that makes
evalbearable. - The JSON Parser - a hand-written scanner-driven parser that never calls back into user code.
- The RegExp Engine - irregexp, the lazy result, the experimental interpreter, and catastrophic backtracking.
- TypedArrays and ArrayBuffers - the bytes live outside the heap, which is why detaching a buffer is a security event.
- BigInt - a sign and a vector of 64-bit digits, and the cliff where the JIT gives up.
- Module Loading - the module graph, the linking phase, and live bindings.
- Array.prototype.sort - PowerSort, a port of CPython’s sort, and the stability guarantee that came with ES2019.
- Snapshots - how a heap is frozen into a file and thawed into a new process.
- The Zone Allocator - the bump allocator that never frees, which is exactly right for a compiler.
- Flags - one hand-written file, included five times, that produces the entire configuration surface of the engine.
9. How to read V8’s source
V8 is large, but it is not evenly large. If you want to understand something, these are the places to look, in the order in which the engine executes them.
| Question | Where to look |
|---|---|
| What does this syntax mean? | src/parsing/, src/ast/ |
| What does this bytecode do? | src/interpreter/bytecodes.h, src/interpreter/interpreter.cc |
| What will the compiler do with it? | src/compiler/js-graph.cc, src/compiler/turbofan-typer.cc |
| Why is my code slow? | src/compiler/load-elimination.cc, src/compiler/escape-analysis-reducer.cc |
| Where does my data live? | src/objects/, src/heap/heap-layout.cc |
| Why did my code get slower? | src/deoptimizer/, src/objects/feedback-vector.tq |
| What does the GC do? | src/heap/scavenger.cc, src/heap/mark-compact.cc |
Two conventions make the tree navigable. First, *.tq files are Torque
source and *.cc files are C++; almost every builtin exists in both, and the
.tq is the one that reads like the language you already know. Second, every
optimization has a *-reducer.cc next to its analysis, and the reducer is
usually the more interesting of the two, because that is where the decision
lives.
Trade-offs
What V8 buys. JavaScript runs at roughly native speed for code that is monomorphic, allocation-heavy and branch-predictable, which is most code that matters. That is not an accident; it is the sum of the forty decisions above.
What it costs.
- Complexity. V8 is roughly a million lines of C++ and Torque. Every one of these tricks is a trade that somebody had to make deliberately, usually by giving up a generality that JavaScript does not need.
- Warm-up. A function has to run a while before V8 knows anything about it. Benchmarks that run a loop 3 times measure the interpreter, not the optimizer, and the gap between them is two orders of magnitude.
- Non-determinism. The same program can take different paths through the engine depending on the feedback it collected, which is why V8 bugs are so often “it only happens after the 1000th iteration”.
- The deoptimization cliff. Everything the optimizer deletes is guarded, and the guard can fail at the worst possible moment. A function that is 100x faster while monomorphic and 10x slower than the interpreter while polymorphic is a real and common shape.
The bottom line
The mental model to keep is not “V8 compiles JavaScript to machine code”. It is closer to this:
- Everything has a shape. Objects, strings, arrays, closures - each one
has a
Mapthat says what it is, and V8 uses thatMapas a proof obligation. - V8 watches before it commits. The feedback vector and the inline caches are the observation layer; the compiler is the hypothesis.
- The optimizer deletes work under a guard. Escape analysis, load elimination, loop peeling - all of them are “if this assumption holds, this code is unnecessary”, and every assumption is checked at runtime.
- The collector is three cheap collectors. Copy the young, mark the old, and use barriers to keep both cheap.
- When in doubt, throw the code away. Deoptimization and code flushing are not failures; they are the mechanism that keeps the fast path honest.
The thing people get wrong
The most common misreading of V8 is that it is “a JavaScript compiler”. It is not. It is a speculative system that keeps a record of what the program did, assumes it will keep doing it, compiles that assumption into machine code, and checks the assumption on every execution.
Which means the interesting questions are never “how fast is V8” but “what did V8 assume, and what happens when the assumption breaks”. Every article in this series is, in the end, about one of those assumptions.