JavaScript Dissected: P2 - Engine Fundamentals
Table of Contents
TL;DR
- JavaScript is a specification. V8, SpiderMonkey, and JavaScriptCore are implementations of it.
- An engine never runs your text directly. It scans it into tokens, parses those into an AST, lowers that into bytecode, runs the bytecode, watches the types that show up, then compiles hot code into speculative machine code that assumes type stability and bails out when the assumption breaks.
- Every value in V8 is tagged at the bit level: Smis have LSB=0, heap pointers have LSB=1. Pointer compression shrinks all references into a 4 GB cage. SpiderMonkey and JSC use NaN-boxing to pack pointers into IEEE 754 NaN space.
- Bytecode is not a toy abstraction. Ignition is a real register-based VM with an accumulator, a constant pool, and per-instruction feedback slots that feed the optimizer. You can dump it with
--print-bytecode.- TurboFan’s sea-of-nodes IR makes control flow, data flow, and side effects explicit as typed graph edges. Speculative guards (
CheckSmi,CheckMaps,CheckBounds) are first-class nodes in that graph.- Hidden classes (Maps) form a transition tree. Adding a property forks the tree; deleting one can force the object into dictionary mode. The shape, not the values, determines whether the fast path fires.
- Inline caches are not just a concept — they’re a concrete data structure: the feedback vector, with slots that transition through uninitialized → monomorphic → polymorphic → megamorphic states.
- Deoptimization is the escape hatch. TurboFan records a TranslationArray that maps optimized frame state back to interpreter frame state. On a failed guard, the deopt trampoline reconstructs interpreter frames and resumes.
- Every one of these mechanisms is an attack surface. Type confusions live in the tag bits, shape confusions live in map transitions, and compiler bugs live in the lowering pipeline.
- The bytecode generator, the parser, linters, formatters, transpilers — all of them walk the AST with depth-first search. Pre-order emits, post-order transforms. V8’s
AstTraversalVisitoris the visitor pattern wired onto a DFS, and once you can visualize the walk order, you can read, instrument, and rewrite any tree.
One line of code, the whole journey
We’ll follow a single function through the entire engine, start to finish:
function tax(price) {
return price * 1.13;
}
tax(100);
To you, this is obvious: multiply a number by 1.13. To the engine, this one line triggers scanning, parsing, scope resolution, bytecode generation, interpretation, type profiling, and possibly optimized machine-code compilation. We’ll unpack each stage using exactly this example, so the pipeline stays concrete instead of abstract.
Here’s the whole route first, then we’ll walk it slowly:
graph LR
SRC["Source text"] --> TOK["Tokens"]
TOK --> AST["AST (w/ scope info)"]
AST --> BC["Bytecode (Ignition)"]
BC --> INT["Interpreter runs bytecode"]
INT --> FB["Type feedback (ICs)"]
FB -->|hot + stable| JIT["Optimizing JIT (TurboFan)"]
JIT --> MC["Machine code w/ speculative guards"]
MC -->|assumption breaks| DEO["Deoptimizer: reconstruct frame, resume interpreter"]
DEO --> BC
MC -->|assumption holds| FAST["Fast path — no going back"]
First, get the layers straight
Before the pipeline, fix one piece of vocabulary that trips up almost everyone. “JavaScript” is not a single thing — it’s three layers stacked together.
| Layer | What it is | Examples |
|---|---|---|
| Language | The ECMAScript specification: syntax, types, objects, semantics. | Array, Promise, +, prototypes |
| Engine | The program that parses, compiles, and runs that language. | V8, SpiderMonkey, JavaScriptCore |
| Host | The environment that embeds the engine and adds APIs. | Chrome, Node.js, Deno, Bun |
The cleanest test is to ask where something comes from. Array.prototype.map is the language. document.querySelector is the browser. process.env is Node.js. A quick experiment makes the split obvious — run this in Node and then in a browser console:
console.log(typeof Array); // both: "function" (language)
console.log(typeof document); // browser: "object", Node: "undefined"
console.log(typeof process); // browser: "undefined", Node: "object"
Array exists everywhere because it’s part of the language. document and process exist only where the host provides them. This matters for security because the layer decides the bug class: a DOM-XSS bug lives in browser host APIs, an RCE via child_process lives in Node host APIs, prototype pollution lives in the language, and type confusion lives in the engine.
The three big engines all follow the same pipeline; only the names differ. We’ll use V8 vocabulary throughout because it powers Chrome and Node.js and gives us the best tooling.
| Idea | V8 | SpiderMonkey (Firefox) | JavaScriptCore (Safari) |
|---|---|---|---|
| Interpreter | Ignition | Interpreter | LLInt |
| Optimizing compiler | TurboFan | Warp / Ion | DFG / FTL |
| Object layout | Map / hidden class | Shape | Structure |
| Value tagging | Smi + pointer tagging | NaN-boxing | NaN-boxing |
Stage 1 — Source to tokens
The engine starts with raw characters and groups them into tokens: the smallest meaningful units. For our function’s key line, return price * 1.13;, the scanner produces:
| Text | Token |
|---|---|
return | keyword |
price | identifier |
* | operator |
1.13 | numeric literal |
; | punctuator |
Whitespace and comments are dropped; source positions are kept so tools can point back at the original code. Tokenizing sounds trivial but isn’t — the same character can mean different things in different contexts.
The slash problem
The toughest ambiguity in JavaScript tokenization is the / character. In a / b, it’s division. In /abc/, it’s the start of a regular expression. The scanner cannot decide locally — it needs to know the grammatical context from the parser. This coupling between scanner and parser is one reason JavaScript tokenization can’t be a pure stateless pipeline.
x = a / b / c; // three divisions
x = a / b / c / g; // WHAT IS THIS?
// a / b / c / g -> division (in expression context)
// /c/g -> regex with flag g (after = operator)
V8’s scanner (src/parsing/scanner.h) calls into the parser to disambiguate: “given the last token I saw, is this / starting a regex or not?” This makes the scanner-parser boundary leaky in both directions.
Unicode and identifiers
JavaScript allows Unicode in identifiers. const café = 1; is legal. The scanner uses the Unicode ID_Start and ID_Continue categories, not just ASCII, to decide whether a character can be part of an identifier. This means the scanner has to handle multi-byte UTF-8 sequences, surrogate pairs, and character property lookups — one more edge case surface where a subtle interpretation difference between the scanner and the spec creates a vulnerability.
const \u0061 = 42; // equivalent to: const a = 42
const \u{1F600} = 1; // emoji variable name — legal!
a + \u0061; // what does this resolve to?
The token stream for our function
Zooming out to the full function function tax(price) { return price * 1.13; }, here’s the complete token list:
keyword function
identifier tax
punctuator (
identifier price
punctuator )
punctuator {
keyword return
identifier price
operator *
numericliteral 1.13
punctuator ;
punctuator }
Episode 3 dives deeper into scanning and parsing; for now, the key takeaway is that tokenization is non-trivial, context-dependent, and Unicode-aware. Every ambiguity here is a potential engine bug if two engines disagree on what a token boundary is.
Interlude — How V8 actually stores values in memory
Before we can understand bytecode, optimization, or type confusion bugs, we need to know how a JavaScript value physically occupies memory. Everything downstream depends on this representation.
The 64-bit tagged value
In V8 (64-bit mode), every JavaScript value is a 64-bit tagged value. The lowest bit (or bits) encodes what kind of thing the 64-bit word represents.
| LSB | Meaning | How to interpret the remaining bits |
|---|---|---|
0 | Smi (small integer) | The 64-bit value >> 1 gives the signed integer. Value 5 is stored as 10 (0b1010). |
1 | HeapObject pointer | The 64-bit value minus the tag is a pointer into the V8 garbage-collected heap. |
A Smi occupies no heap allocation. It lives entirely inside the 64-bit slot. The cost: Smi range is 31 bits signed on 32-bit builds (1 bit stolen for the tag), and 32 bits signed on 64-bit — pointer compression doesn’t expand the range; it only shrinks how heap pointers are stored. Values outside Smi range become HeapNumbers — boxed doubles stored on the heap.
let x = 1; // Smi: stored as 0x0000000000000002
let y = 0x7FFFFFFF; // Smi: biggest positive Smi on 32-bit
let z = 0x80000000; // HeapNumber: doesn't fit in Smi range
let w = 3.14; // HeapNumber: not an integer
The actual tag check in V8 is done with a single bit test: value & 1 == 0 → Smi, else → heap object. This is why engine source code is littered with IsSmi() and HAS_SMI_TAG macros.
HeapObject pointer tagging
A heap object pointer has tag 01 (bit 0 = 1, bit 1 = 0). V8 uses this because heap allocations are at least 4-byte aligned, so the low 2 bits of a real pointer are always 00. The engine ors in the tag:
Real pointer: 0x0000ABCD12340000 (8-byte aligned)
Tagged form: 0x0000ABCD12340001 (OR with 0x01)
Access: tagged & ~1 → dereference
There is also a weak pointer tag (11, bits 0-1 both set) used in certain contexts such as feedback vector slots and weak reference cells, where the GC must be able to clear the reference without the slot holder’s involvement.
Pointer compression
Since V8 ~8.0 (enabled by default on 64-bit), the heap lives inside a 4 GB cage — a contiguous virtual address reservation. Every heap pointer is stored as a 32-bit offset from the cage base, and the upper 32 bits of every tagged slot are zero.
Tagged slot (64 bits): [zeroed upper 32] [32-bit offset | tag]
Actual pointer: cage_base + (offset & ~tag_mask)
This halves the memory cost of every pointer (from 8 bytes to an effective 4 bytes), but it does not affect Smis — Smis still encode the integer directly in the lower bits. Pointer compression is transparent to JavaScript, but it’s not transparent to an exploit writer: you now need an info leak that reveals the cage base before you can construct a fake object.
NaN-boxing — how the other engines do it
SpiderMonkey and JavaScriptCore use a completely different scheme called NaN-boxing, which exploits the fact that IEEE 754 doubles have 2^52 - 1 unused NaN representations.
IEEE 754 double layout:
[sign:1] [exponent:11] [mantissa:52]
NaN: exponent = all 1s, mantissa ≠ 0
→ 2^52 - 1 possible NaN bit patterns, but JS only needs one.
SpiderMonkey encodes non-double values (int32, bool, null, undefined, object pointers, strings) into the mantissa bits of a canonical NaN. A pointer on x86-64 uses only 48 address bits, leaving plenty of room in the 52-bit mantissa. On ARM64 with pointer authentication, the space gets tighter — this is a real portability headache that has caused JIT bugs.
// Conceptual NaN-boxing (simplified):
// double value → stored as IEEE 754 double
// int32 value → stored in NaN mantissa with type tag
// object pointer → stored in NaN mantissa with object tag
Why this matters for security: if any code path forgets to check the tag and interprets a pointer as a double or vice versa, you have a type confusion — the root of nearly every JIT engine exploit. That one missed IsSmi() check or one NaN-box unboxing without validation is the difference between “this engine is secure” and “arbitrary code execution.”
The HeapNumber trap
When a number doesn’t fit in Smi range, V8 allocates a HeapNumber — a heap-allocated object containing an IEEE 754 double. Every operation on a HeapNumber involves a heap load, making it slower than Smi arithmetic. The optimizer works hard to keep values in Smi range, and Smi overflow is one reason the JIT inserts deopt guards.
let a = 1; // Smi
let b = 0x40000000; // Smi (still fits)
let c = a + b; // Result is still Smi → stays fast
let d = 0x40000000 * 0x100; // Overflow! → HeapNumber, possible deopt
Stage 2 — Tokens to an AST
The parser turns that flat token stream into a tree that captures structure and precedence. return price * 1.13 becomes:
graph TD
R["ReturnStatement"] --> M["BinaryExpression: *"]
M --> P["Identifier: price (resolved to parameter)"]
M --> N["Literal: 1.13"]
The tree is the first durable statement of meaning: this is a return, of a multiplication, of a variable by a constant. The engine also resolves bindings here — it works out that price refers to the function’s parameter, not some other variable of the same name elsewhere. This scope resolution produces ScopeInfo objects attached to the AST nodes.
Lazy parsing: don’t compile what you won’t run
V8 does not parse every function in your file upfront. It runs lazy (deferred) parsing — for a function that isn’t immediately invoked, V8 only does a quick pre-parse that checks syntax and records the function’s source range. The full parse (and subsequent bytecode generation) is deferred until the function is first called.
// Only the outer script is fully parsed.
// helper() is pre-parsed — syntax-checked, but no AST generated.
function helper() {
return expensiveComputation(); // won't be parsed until called
}
helper(); // NOW it gets fully parsed, bytecode-generated, and run
The pre-parser skips the body of every function it sees, but it must still track opening/closing braces to find function boundaries. A bug in the pre-parser — missing an edge case in brace matching — can cause V8 to misinterpret where a function ends, which can cascade into a very wrong AST.
Eager parsing by necessity
Some constructs force V8 to eagerly (fully) parse. The big ones:
| Trigger | Why it forces eager parsing |
|---|---|
eval() | Variables could be declared by the evaluated string, so scope analysis can’t be deferred. |
with statement | with(obj) { x = 1 } — x might be a property of obj or a variable. You can’t know without full parsing. |
Parenthesized function expressions (function() { ... }) | The parens signal that this is an IIFE — it’ll be called immediately. |
import() / top-level await | Module semantics require knowing the full scope graph. |
eval and with are the worst offenders — they poison the entire containing scope. A single eval() anywhere in a function disables lazy parsing for the whole function, and it also blocks many of TurboFan’s optimizations downstream.
The AST-to-bytecode lowering
The parser in V8 (src/parsing/parser.cc) produces an AST, but the next phase — the bytecode generator (src/interpreter/bytecode-generator.cc) — does a final walk over that AST and emits Ignition bytecode. This walk also allocates feedback vector slots — each bytecode that might benefit from type feedback (property loads, binary operations, calls, etc.) reserves an index in the function’s feedback vector. More on that in the IC deep dive.
The bytecode generator is, fundamentally, a DFS traversal of the AST. Every engine phase that touches the tree — the parser building it, the bytecode generator lowering it, even TurboFan’s graph builder — relies on the same traversal pattern. If you can visualize a DFS walk over the AST, you understand how the engine visits your code.
Deep Dive — DFS Tree Traversal in Engines
Every tool that operates on source code — engines, linters, formatters, transpilers — walks the AST. And the walker is almost always a depth-first search.
Why DFS?
An AST is a hierarchical tree. A FunctionDeclaration contains a BlockStatement, which contains a ReturnStatement, which contains a BinaryExpression, which contains an Identifier and a Literal. That nesting is the entire structure. DFS visits nodes by going as deep as possible along one branch before backtracking, which matches how JavaScript executes — the return statement runs inside the block, which runs inside the function.
graph TD
FD["FunctionDeclaration: tax"] --> BS["BlockStatement"]
BS --> RS["ReturnStatement"]
RS --> BE["BinaryExpression: *"]
BE --> ID["Identifier: price"]
BE --> LIT["Literal: 1.13"]
Pre-order, post-order, and when each matters
There are three classic DFS variants, and engines use all of them:
| Traversal | Order | When it’s used |
|---|---|---|
| Pre-order | Visit node, then children | Bytecode generation: emit the outer instruction before the inner ones. Scoping: declare the function before entering its body. |
| Post-order | Visit children, then node | Code generation in many compilers: evaluate operands first, then apply the operation. Constant folding: compute leaves, then combine. |
| In-order | Left child, node, right child | Rare in ASTs (ASTs aren’t binary search trees; mostly used for expression trees). |
For our tax function, a pre-order walk touches nodes in this order:
1. FunctionDeclaration: tax ← enter function
2. BlockStatement ← enter body
3. ReturnStatement ← enter statement
4. BinaryExpression: * ← enter operation
5. Identifier: price ← left operand (leaf)
6. Literal: 1.13 ← right operand (leaf)
[post: combine operands at BinaryExpression]
[post: finalize ReturnStatement]
[post: finalize BlockStatement]
[post: finalize FunctionDeclaration]
Every node is visited exactly once. The leaves (Identifier, Literal) are hit before their parent (BinaryExpression) in pre-order, and after in post-order. The engine chooses where to emit code based on which pass it’s in.
V8’s visitor pattern
V8 doesn’t write raw DFS loops everywhere. It uses the visitor pattern via AstTraversalVisitor (src/ast/ast-traversal-visitor.h). Every AST node type defines an Accept(visitor) method. The visitor then walks the tree by calling Accept() on each child — this is the recursive step of DFS.
// Conceptual skeleton — not exact V8 source, but the pattern:
class BytecodeGenerator : public AstVisitor {
void VisitFunctionDeclaration(FunctionDeclaration* node) {
// PRE-ORDER: allocate feedback vector, set up scope
AllocateFeedbackVector(node);
EnterScope(node->scope());
// DFS: visit the body (recurses into children)
Visit(node->body());
// POST-ORDER: finalize, emit return
ExitScope(node->scope());
EmitReturn();
}
void VisitReturnStatement(ReturnStatement* node) {
// PRE-ORDER: nothing special
// DFS: visit the expression first (need the value before we can return it)
Visit(node->expression());
// POST-ORDER: emit the actual Return bytecode
Bytecode() << Return;
}
void VisitBinaryExpression(BinaryExpression* node) {
// DFS: left operand
Visit(node->left());
// DFS: right operand
Visit(node->right());
// POST-ORDER: emit the operation
EmitBinaryOp(node->op(), feedback_slot);
}
void VisitIdentifier(Identifier* node) {
// Leaf node — emit a load based on where this variable lives
if (node->IsParameter()) {
Bytecode() << Ldar(parameter_index);
} else if (node->IsLocal()) {
Bytecode() << Ldar(register_index);
}
}
void VisitLiteral(Literal* node) {
// Leaf node — load the constant
auto idx = AddToConstantPool(node->value());
Bytecode() << LdaConstant(idx);
}
};
This pattern means the bytecode generator’s logic is distributed across Visit* methods — one per AST node type. But the structure — the order nodes are visited — is always DFS. The visitor dispatches; DFS decides when.
The recursion problem: what happens with deeply nested code?
Pure recursive DFS can blow the C++ call stack. Consider:
function deep() {
if (cond) {
return a + b;
} else {
if (cond2) {
return c + d;
} else {
if (cond3) {
return e + f;
} else {
// ... 10,000 more nesting levels
}
}
}
}
V8 mitigates deep recursion primarily through lazy parsing rather than an explicit traversal stack. The reality is that extreme nesting is rare in real-world code, and V8 simply doesn’t parse the body of functions that aren’t yet called, so the AST is never fully in memory at once. That’s the real scalability mechanism.
Building tools that walk the AST
Once you understand DFS, you can build your own tree walkers. Every static analysis tool is just a visitor:
// Conceptual: a linter that finds return statements without braces
class NoBracesLinter {
walk(node) {
if (node.type === 'ReturnStatement' && node.argument.type === 'BinaryExpression') {
console.warn(`Return with expression at line ${node.loc.start.line}: consider braces`);
}
// DFS: recurse into children
for (const child of node.children || []) {
this.walk(child);
}
}
}
ESLint, Babel, Prettier, TypeScript’s compiler, and every JS engine all use variations of this pattern. The difference is when in the traversal you act — pre-order for emitting, post-order for transforming, both for analyzing.
Visualizing the traversal with AST Explorer
Go to astexplorer.net, paste in function tax(price) { return price * 1.13; }, and select the @babel/parser or acorn parser. Hover over each node. You’re looking at the same tree structure V8 builds internally. Now mentally walk it in DFS order — that’s the exact sequence the bytecode generator follows.
Stage 3 — AST to bytecode
The engine doesn’t execute the tree directly. It lowers it into bytecode: compact, linear instructions for the interpreter.
Ignition: a register-based virtual machine
Ignition is a register-based interpreter. Each function frame has up to ~128 virtual registers (plus the accumulator, an implicit register most instructions read from and write to). This is different from a stack-based VM (like the JVM), where every operation pushes and pops. Register-based interpreters generate more compact bytecode for JavaScript because typical JS code chains many operations, and register operands avoid the push-pop shuffle.
Conceptually, return price * 1.13 becomes:
load price # acc = price
mul 1.13 # acc = acc * 1.13
return # return acc
But the real bytecode is richer. Let’s look at it.
Dumping real Ignition bytecode
Save this as tax.js:
function tax(price) {
return price * 1.13;
}
tax(100);
Then dump bytecode:
node --print-bytecode --print-bytecode-filter=tax tax.js
You’ll see output similar to this (Node.js v22.x / V8 12.x; exact addresses and opcode bytes may differ across versions):
[generated bytecode for function: tax (0x03f4001dbbf9 <SharedFunctionInfo tax>)]
Bytecode length: 10
Parameter count: 2
Register count: 1
Frame size: 0
0x3f4001dbd4a @ 0 : 13 00 LdaConstant [0]
0x3f4001dbd50 @ 2 : 26 f9 Star r1
0x3f4001dbd54 @ 4 : 0b 04 Ldar a1
0x3f4001dbd58 @ 6 : 3a 01 00 Mul r1, [0]
0x3f4001dbd5c @ 9 : aa Return
Constant pool (size = 1):
0x3f4001dbd60: [FixedArray]
0: 0x03f4001dbd71 <HeapNumber 1.13>
Let me decode each instruction, because this is where most people’s eyes glaze over — but once you can read this, you’re reading the exact same disassembly the V8 team reads during debugging.
| Offset | Bytecode | Hex | What it does |
|---|---|---|---|
@ 0 | LdaConstant [0] | 13 00 | Load constant pool entry [0] (the HeapNumber 1.13) into the accumulator. |
@ 2 | Star r1 | 26 f9 | Store the accumulator into register r1. f9 = -7 in two’s complement, mapping to frame register 1. |
@ 4 | Ldar a1 | 0b 04 | Load argument 1 (price) into the accumulator. 04 encodes argument index 1. |
@ 6 | Mul r1, [0] | 3a 01 00 | Multiply accumulator by r1, store result in accumulator. [0] is the feedback vector slot that records which types (Smi, HeapNumber, etc.) show up at this multiply site. |
@ 9 | Return | aa | Return the accumulator to the caller. |
Key detail: LdaConstant [0] loads a value from the constant pool — in this case the HeapNumber that represents 1.13. This is not the same as LdaGlobal, which loads a named variable from the global scope by name index. Float literals are heap-allocated constants, not globals.
Anatomy of a bytecode instruction
Each Ignition bytecode is 1 to 4 bytes wide:
[opcode: 1 byte] [operand 0] [operand 1] ...
Operands can be:
- Reg — a register index, encoded in 1 byte (unsigned, or 2’s complement for negative offsets)
- Idx — a constant pool index, 1-2 bytes
- UImm / Imm — unsigned/signed immediate value (1-2 bytes)
- Feedback slot — an index into the function’s feedback vector (1-2 bytes)
The [0] you see after Mul r1, [0] is the feedback slot. It means “when this multiply executes, record what types you see in slot 0 of this function’s feedback vector.” That slot accumulates the profile: “I’ve seen (Smi × Smi)” or “I’ve seen (Smi × HeapNumber)” or “I’ve seen strings” — and TurboFan reads exactly that profile when deciding whether to compile and what guards to emit.
The constant pool
Values that aren’t Smis — strings, HeapNumbers, object boilerplates, regex literals — live in the function’s constant pool, a FixedArray attached to the SharedFunctionInfo. When bytecode references LdaConstant [0], it indexes into this array. The constant pool is shared across all closures created from the same function literal.
The accumulator pattern
Ignition is centered on the accumulator — a virtual register that’s the implicit source and/or destination of nearly every instruction. Ldar a1 reads into it, Mul r1 modifies it, Return reads from it. The pattern: load into acc, operate on acc, store acc to register, repeat. This accumulator-centric design is what makes Ignition bytecode compact; you never encode a destination operand because it’s always implied.
Where feedback slots live
The function’s FeedbackVector is a FixedArray allocated when the function is first called. Bytecode instructions that carry a feedback slot index write their observations into a specific cell of that array. The mapping is: feedback slot N corresponds to the Nth instruction in the bytecode that requested feedback. The bytecode generator does a pre-pass to count how many slots are needed and allocates the vector accordingly.
Deep Dive — TurboFan’s Sea of Nodes
Once a function is hot, the bytecode and its feedback vector are handed to TurboFan, V8’s optimizing compiler. TurboFan doesn’t think in terms of basic blocks — it builds a sea of nodes graph.
Nodes, not blocks
In a traditional compiler, you’d lower bytecode to a control-flow graph of basic blocks, then optimize within and across blocks. TurboFan skips the block structure entirely (at first). Your entire function becomes a single graph where:
- Every operation is a Node
- Edges represent three kinds of dependency:
| Edge kind | Meaning | Example |
|---|---|---|
| Data (value) | Node B consumes the output of Node A | Mul consumes the outputs of two input nodes |
| Effect | Node B must happen after Node A because A has a side effect | A property store must happen after the object is allocated |
| Control | Node B executes only if node A’s control path is taken | The Return node is control-dependent on the function body completing |
This triple of edge types means the graph simultaneously encodes data flow (what values flow where), effect ordering (what side effects must serialize), and control flow (which nodes execute under which conditions).
The graph for our tax function
For function tax(price) { return price * 1.13; }, the sea-of-nodes graph looks roughly like this at the JavaScript tier:
[Parameter: price] [HeapConstant: 1.13]
\ /
\ /
[SpeculativeNumberMultiply]
|
[CheckSmi | CheckNumber] (guard)
|
[Return]
Key node types in the pipeline:
| Node | Purpose |
|---|---|
Parameter | Represents a function argument. |
HeapConstant | A reference to a heap object (like our 1.13 HeapNumber). |
SpeculativeNumberMultiply | Multiply, assuming both operands are numbers. Lower-level nodes will implement the actual operation. |
CheckSmi | Guard: deoptimize if the value is not a Smi. |
CheckMaps | Guard: deoptimize if the object doesn’t have the expected Map. |
CheckBounds | Guard: deoptimize if an array index is out of bounds. |
Checkpoint | Marks a deoptimization safepoint — a location where the interpreter state is recoverable. |
The reduction pipeline
TurboFan runs nodes through a sequence of optimization phases. Each phase either replaces a node with lower-level nodes or eliminates it entirely:
JavaScript tier → Simplified tier → Machine tier → Code generation
- JavaScript tier: Nodes are high-level (
JSCall,JSLoadProperty,SpeculativeNumberMultiply) — they understand JavaScript semantics like coercion and prototype lookup. - Typed lowering: Based on feedback, high-level nodes are replaced with more specific ones. A
JSCallwith monomorphic feedback becomes a direct call.JSLoadPropertyat a monomorphic site becomes a field load at a known offset. - Simplified tier: Nodes are architecture-independent low-level operations (
Int32Add,Float64Mul,LoadField,StoreField). - Machine tier: Nodes correspond to actual CPU instructions (
Int32Add→addl,Float64Mul→mulsdon x64). - Code generation: The graph is scheduled into basic blocks, registers are allocated, and native code is emitted.
How speculation becomes a node
When feedback says “the multiply always sees Smis,” TurboFan inserts a CheckSmi guard before the multiplication and lowers the multiply to Int32Mul. If the guard fails, deopt. If it passes, fast integer arithmetic.
When feedback is polymorphic (“sometimes Smi, sometimes HeapNumber”), the guard is softer (CheckNumber), and the multiply stays as a Float64Mul, which works for both. The graph branches on the type checks — but unlike source-level if statements, these branches are speculative and invisible to JavaScript.
The effect chain
Side-effecting operations (property stores, calls, array writes) are serialized through the effect chain — a linked list threading through the graph. Two stores to the same object must happen in order, so they share the effect chain. But two stores to different objects, which the optimizer can prove don’t alias, can have independent effect chains and be reordered. This is where alias analysis bugs live: if the optimizer incorrectly decides two operations don’t alias, it can reorder them, and the observable behavior changes.
Stage 4 — Running, watching, and optimizing
When execution starts, the interpreter (Ignition) runs the bytecode immediately — fast to start, but not the fastest way to run something a million times. So while it runs, the engine records type feedback: what kinds of values actually show up.
Call tax(100), tax(250), tax(19) a few thousand times and the feedback settles into a clear profile — the argument is always a number, the multiplication is always numeric, and the result is always a number. Once the function is hot (called enough) and stable (its feedback stops changing), the optimizing compiler (TurboFan) uses that profile to generate specialized machine code.
The tier-up dance
V8’s compilation tiers are not a one-way street. A function moves through them based on hotness counters:
Uncompiled (lazy)
→ Bytecode compiled (Ignition)
→ [feedback collected, counters increment]
→ Optimized (TurboFan)
→ [speculation fails] → Deoptimized (back to Ignition)
→ [re-optimized with updated feedback] → Optimized (TurboFan)
This is the optimize-deoptimize-reoptimize cycle. V8 keeps a “deopt count” per function; if it deopts too many times, the function is marked “don’t optimize” to prevent thrashing.
On-Stack Replacement (OSR)
What if a function is already mid-loop when it gets hot? V8 supports On-Stack Replacement — the ability to swap a running function’s execution from the interpreter to optimized code while it’s executing. The interpreter sets up OSR entry points at loop headers; when the loop’s iteration counter hits the threshold, TurboFan generates an OSR-compiled version of the function that picks up exactly where the interpreter left off.
OSR-compiled code is slightly different from normally-compiled code because it must accept the loop’s current state (registers, local variables) as input rather than starting from the function entry. This makes OSR a frequent source of compiler bugs — the state reconstruction is more complex than a normal compile.
What feedback actually looks like
The feedback vector for tax after warming up on numbers:
Feedback Vector (for function tax):
Slot 0 (allocated but unused in this example)
Slot 1 (the Mul at offset @7):
[BinaryOpFeedback]
type: Smi | HeapNumber (numeric)
...
If you call tax("10") once, the feedback widens:
Slot 1:
[BinaryOpFeedback]
type: Smi | HeapNumber | String
The key word is speculative. The optimized code doesn’t re-check everything the way the interpreter did. It assumes the profile holds and inserts a cheap guard up front:
if price is not a number → bail out to bytecode
else → fast numeric multiply
That guard is the safety rope. As long as you keep calling tax with numbers, you run the fast path. The moment you don’t, the engine has to unwind.
Deep Dive — The Inline Cache data structure
“IC” is not an abstraction. It’s a concrete slab of memory inside the feedback vector.
The feedback vector layout
Every JSFunction has a FeedbackVector — a FixedArray whose indices are allocated during bytecode generation. Conceptually:
FeedbackVector (FixedArray):
[0] shared function info reference
[1] optimized code / marker
[2] invocation count
[3] feedback slot 0 ← Bytecode instruction uses this slot
[4] feedback slot 1
[5] feedback slot 2
...
Each slot is one FeedbackNexus — an object that wraps the raw slot data and gives you typed access.
Monomorphic: one map, one handler
The fastest state. The slot holds a weak reference to one Map and a handler that says what to do when you see that Map.
For a property load like obj.role:
Slot:
[WeakRef to Map_of_{name,role}]
[Handler: Smi(12)] ← "field is at in-object offset 12"
When the IC stub executes obj.role, it:
- Loads
obj’s Map. - Compares to the cached weak reference.
- If match → loads from in-object offset 12. Three instructions.
- If no match → calls the runtime
LoadIC_Miss, which does the full prototype chain lookup and updates the slot.
This is where the performance comes from. A dictionary lookup becomes a pointer comparison + offset load.
Polymorphic: a small array of (map, handler) pairs
When the IC sees a second or third map, it stays polymorphic — the slot becomes a small array:
Slot:
[FixedArray:
[WeakRef Map_A] [Handler A]
[WeakRef Map_B] [Handler B]
[WeakRef Map_C] [Handler C]
]
The compiled stub now does a linear scan: compare map, match → use handler, else → next. Up to 3-4 entries, this is still fast. The limit (called kMaxPolymorphicMapCount) is typically 4 in V8.
Megamorphic: give up
When the IC sees more than the polymorphic limit, it transitions to megamorphic. The slot is replaced with a MegamorphicSymbol. From this point on, every access goes through the generic runtime path — no more fast stubs.
// Keep the IC monomorphic:
function getRole(u) { return u.role; }
for (let i = 0; i < 100000; i++) getRole({ role: "a", name: "x" });
// Blow it to megamorphic:
for (let i = 0; i < 100; i++) {
let obj = { role: i };
if (i % 2) obj.extra = true; // different shapes!
getRole(obj);
}
// IC is now megamorphic — every call goes through the slow path
The handler types
The handler stored alongside the map reference is not just a Smi offset. V8 has multiple handler types:
| Handler type | Encoded as | Meaning |
|---|---|---|
| Field load | Smi (in-object offset) | The property is at a fixed offset inside the object. |
| Constant | A Code object | The property is a constant value (from a const or a class field) — return it directly. |
| Accessor | A Code object | The property is a getter — call the getter function. |
| Dictionary | A Code object | The object is in dictionary (slow) mode — do a hash lookup. |
For StoreIC (property stores), handlers also track whether the store triggers a map transition (adding a new property changes the object’s shape → the IC must update the Map reference after the store).
Why IC bugs are so dangerous
If a type confusion allows an attacker to corrupt the feedback vector — specifically, replace a handler Smi with an attacker-controlled value — the next IC access will load from an arbitrary offset. This is an OOB read primitive. If the corrupted slot is for a StoreIC, it becomes an OOB write primitive. This is perhaps the most common exploitation path in V8 bug bounty write-ups: corrupt the feedback vector, redirect a load/store, build arbitrary read/write.
Stage 5 — Deoptimization
Deoptimization isn’t an error; it’s the escape hatch that keeps speculation safe. Watch it happen. Save deopt.js:
function tax(price) {
return price * 1.13;
}
// Warm up: train the optimizer on numbers.
for (let i = 0; i < 100000; i++) {
tax(i);
}
console.log(tax(100)); // 113 — fast numeric path
console.log(tax("100")); // 113 — string is coerced, assumption shifts
Run it with tracing:
node --trace-opt --trace-deopt deopt.js
Both calls return 113, because "100" * 1.13 coerces the string to a number. But the string call violates the “always a number” profile the optimizer trained on, so the engine bails out of the fast code and falls back to the interpreter. That round-trip — optimize, hit a surprise, deoptimize — is the single most important rhythm in modern JS engines, and the exact place where engine exploitation begins. In a correct engine, the result is always right and only speed suffers. In a buggy engine, a wrong assumption can survive, and memory gets interpreted the wrong way.
How deoptimization actually works
Deoptimization is frame reconstruction. When TurboFan compiles a function, it doesn’t just emit machine code — it also emits a TranslationArray that maps every safepoint in the optimized code back to the equivalent interpreter state.
The steps when a speculative guard fails:
1. Guard check fails (e.g., CheckSmi sees a HeapNumber).
2. Jump to deoptimization trampoline (a small assembly stub).
3. Deoptimizer allocates an interpreter frame.
4. Deoptimizer walks the TranslationArray for this safepoint,
mapping each live optimized value to its interpreter counterpart:
- Register → local variable slot
- Stack slot → expression stack position
5. Values are converted: Smi → tagged int, HeapObject → tagged pointer.
6. Stack pointer and frame pointer are adjusted to point at the new interpreter frame.
7. Execution resumes at the bytecode offset corresponding to the safepoint.
The TranslationArray
A Translation (one entry in the array) is essentially a serialized state snapshot:
Translation for safepoint at PC offset 0x45:
- Frame size: 2 locals, 1 expression stack element
- Local 0 (price): lives in register r8 → copy to interpreter local slot 0
- Accumulator: lives in register rax → set interpreter accumulator
- Bytecode offset to resume: @7 (the Mul instruction)
V8’s Deoptimizer class (src/deoptimizer/deoptimizer.cc) is ~5,000 lines of code dedicated entirely to this frame reconstruction — that’s how much complexity the escape hatch introduces.
Lazy vs. eager deoptimization
| Type | Trigger | Cost |
|---|---|---|
| Lazy deopt | A speculative guard fails during execution (e.g., wrong type, out of bounds). | Medium — reconstruct one frame. This is the common case. |
| Eager deopt | A code dependency is invalidated (e.g., prototype is modified, a Map becomes unstable). | High — all optimized frames of all functions that depended on the changed assumption must be deoptimized at once. |
Eager deopt is relatively rare but catastrophic for performance when it happens — it can deoptimize dozens of functions simultaneously.
Code dependencies
TurboFan records what assumptions its optimized code makes. These are tracked as code dependencies on objects (Maps, prototype chains, constant values). If any dependency is invalidated — say, someone assigns to a prototype property that was assumed constant — V8 marks all dependent optimized code for deoptimization.
function hasFlag(o) {
return o.flag === true;
}
const obj = { name: "Ava" };
for (let i = 0; i < 100000; i++) hasFlag(obj);
// Code is optimized. Dependency: "objects like obj have no 'flag'."
Object.prototype.flag = true; // Invalidates the dependency.
// → Eager deopt: all code that assumed "no flag on prototype" is thrown out.
console.log(hasFlag(obj)); // Correctly returns true (via interpreter).
The deopt loop and re-optimization
When a function deopts, V8 increments a “deopt count.” If the count stays low, the function may be re-optimized — but this time with wider feedback that includes the previously surprising case. If the count goes too high (thrashing), V8 marks the function “don’t optimize” and leaves it in the interpreter permanently.
function flip(x) {
return x.a + x.b;
}
// Train on numbers.
for (let i = 0; i < 50000; i++) flip({ a: 1, b: 2 });
// Optimized for numbers.
// Throw a string: deopt #1, re-optimize with wider feedback.
flip({ a: "hello", b: "world" });
// Throw a boolean: deopt #2.
flip({ a: true, b: false });
// Keep going, deopt too many times → "don't optimize" flag set.
// flip() now runs in the interpreter forever.
Why dynamic typing makes this hard — revisited at the bit level
Everything above exists because JavaScript variables don’t have fixed types. A single slot can hold anything, one moment to the next:
let x = 42; // V8: Smi, stored as 0x00000054
x = "forty-two"; // V8: String pointer, tagged with LSB=1
x = { n: 42 }; // V8: JSObject pointer, tagged with LSB=1
x = null; // V8: special sentinel (0x00000002 or 0x0000000a depending on type)
So the engine tags every value with enough information to know what it is at runtime. A type confusion bug is exactly what happens when optimized code treats a value as one type while it’s really another: read an object pointer as if it were a number, or vice versa, and you can read or write memory you shouldn’t.
What a type confusion exploit feels like
Smi 0x12345678 is stored as 0x2468ACF0 (shifted left, LSB=0)
Pointer 0x12345678 is stored as 0x12345679 (OR with 0x01)
If the engine sees 0x12345679 and does (value >> 1) as if it were a Smi:
→ 0x091A2B3C → attacker now has an arbitrary 32-bit value
If the engine sees 0x2468ACF0 and dereferences it as if it were a pointer:
→ SIGSEGV or (worse) read attacker-controlled memory
This is not theoretical. Real V8 CVEs (CVE-2021-30517, CVE-2023-2033, etc.) are exactly this: a missed type check in the JIT pipeline that lets a Smi be treated as a pointer or vice versa.
The overloaded + operator is the cleanest illustration of why the optimizer can never fully relax:
1 + 2 // 3 number + number
"1" + 2 // "12" string wins → concatenation
true + 1 // 2 true coerces to 1
[] + 1 // "1" [] → "", then concatenation
{} + 1 // "[object Object]1"
Every one of those is legal. Train + on numbers and the optimizer will specialize for arithmetic — but it must always guard against the day a string shows up.
Objects have shapes
At the language level, an object looks like a flexible dictionary:
const user = { name: "Ava", role: "admin" };
user.role; // "admin"
If every property access were a dictionary search, JavaScript would be slow. So V8 gives objects a hidden class (internally, a Map) that records which properties exist and at which offset. Objects built the same way share the same Map, and access becomes “if the Map is X, role lives at offset 1” — a couple of instructions instead of a search.
The Map data structure
A V8 Map (src/objects/map.h) is a heap object that stores:
| Field | What it contains |
|---|---|
| Instance descriptors | A DescriptorArray — the ordered list of property names and their attributes (offset, writable, enumerable, configurable). |
| Instance size | How many bytes this object occupies (in-object properties + optional properties array pointer). |
| In-object properties count | How many properties are stored directly inside the object vs. spilled to the backing store. |
| Transition array | Edges to other Maps. Adding a new property follows a transition edge to the new Map. |
| Elements kind | How array-indexed properties are stored (PACKED_SMI_ELEMENTS, HOLEY_ELEMENTS, DICTIONARY_ELEMENTS, etc.). |
| Back pointer | Reverse edge: which Map did this one transition from? |
| Bit fields | Stable, deprecated, dictionary-mode flags, prototype info, etc. |
The map transition tree
Construction history is what determines the shape. When you create { name: "Ava", role: "admin" }, V8 builds a chain:
RootMap (empty object, no properties)
|
| add "name"
v
Map₁ { name at offset 0 }
|
| add "role"
v
Map₂ { name at offset 0, role at offset 1 }
Every object built with { name: ..., role: ... } in that order shares Map₂. They’re shape-compatible even with different values:
const a = { name: "Ava", role: "admin" }; // Map₂
const b = { name: "Noor", role: "user" }; // Map₂ — same Map!
a.role; // IC: "if Map == Map₂, load from offset 1" → fast
But reverse the order and you get a different branch in the transition tree:
const c = { role: "guest", name: "Mina" };
// RootMap → add "role" → Map₃ → add "name" → Map₄
// c has Map₄, NOT Map₂.
// a.role loads from offset 1, but c.role loads from offset 0.
Same keys, different order, different shape — completely invisible at the language level, completely crucial at the engine level.
In-object vs. properties array
V8 reserves room for the first N properties directly inside the JSObject’s memory. This is the in-object properties area. Beyond N, properties spill into a properties array (a separate FixedArray allocation).
const obj = { a: 1 }; // in-object
obj.b = 2; obj.c = 3; obj.d = 4; // still in-object (if N >= 4)
obj.e = 5; obj.f = 6; obj.g = 7; // spills to properties array when N exceeded
Each property in the descriptors has a representation and field_index. In-object properties have small field indices (0, 1, 2…). Spilled properties have field indices beyond the in-object limit, pointing into the properties array.
Dictionary mode: when objects go slow
When you delete a property, or add properties in a pattern that creates a deep transition tree, V8 eventually gives up and converts the object to dictionary mode (DICTIONARY_ELEMENTS / NameDictionary). In dictionary mode:
- Properties are stored in a hash table, not at fixed offsets.
- Every access requires a hash lookup.
- The IC handler changes to a dictionary lookup stub.
- Performance drops to roughly 1/10th of fast-mode access.
const obj = { a: 1, b: 2, c: 3 };
delete obj.b; // forces dictionary mode in many cases
obj.d = 4; // now a dictionary entry, not a fast transition
The delete operator is one of the most expensive things you can do in performance-critical JavaScript. V8 has heuristics to avoid dictionary mode for some delete patterns, but the safest rule is: never delete properties on hot objects.
Elements vs. properties
Named properties (obj.name, obj.role) use one storage system (in-object + properties array). Array-indexed properties (obj[0], obj[1]) use a separate elements storage with its own ElementsKind lattice:
PACKED_SMI_ELEMENTS ← all elements are Smis, no holes (fastest)
→ PACKED_DOUBLE_ELEMENTS ← a float appeared
→ PACKED_ELEMENTS ← an object appeared
→ HOLEY_SMI_ELEMENTS ← a hole appeared
→ HOLEY_DOUBLE_ELEMENTS
→ HOLEY_ELEMENTS
→ DICTIONARY_ELEMENTS (slowest)
The ElementsKind only ever moves down this lattice — never back up. Once you introduce a hole, the array is “holey” forever. This is why reading arr[0] on a dense array is faster than on a sparse one: holey arrays require a bounds + hole check on every access.
Bonus: prototypes can invalidate everything
One more reason optimization is hard: you can change the rules at runtime by touching a prototype.
function hasFlag(o) {
return o.flag === true;
}
const obj = { name: "Ava" };
for (let i = 0; i < 100000; i++) hasFlag(obj); // trains: "flag is missing → false"
Object.prototype.flag = true; // change the world
console.log(hasFlag(obj)); // must now be true
The correct answer flips to true. A correct engine tracks that the optimized code depended on “objects like obj have no flag” and throws that code away when the prototype changes. This is implemented via prototype chain checks encoded as code dependencies: TurboFan records that “for this load, the prototype chain up to Object.prototype had no flag property,” and when someone sets Object.prototype.flag, all code with that dependency is eagerly deoptimized.
This dynamic-lookup behavior is also exactly what prototype pollution abuses — a topic we’ll dedicate a full episode to later. The short version: if an attacker can set Object.prototype.flag = true via a merge operation that doesn’t sanitize __proto__, they can change the behavior of every o.flag access in the application, not just the one they targeted.
The Bug Bounty Lens — where every pipeline stage breaks
This is a map that connects what you’ve learned to where real money is made.
Stage 1 (tokenizer) — Bug surface: small but real
- Unicode normalization mismatches between engines:
\u0061vs.a— does every engine treat them identically? - Regex/division ambiguity: a parser-scanner desync can lead to a malformed AST, and if the engine doesn’t validate the AST before lowering to bytecode, you might get arbitrary bytecode execution.
- Multi-byte UTF-8 overlong encodings: does the scanner correctly reject them, or does an overlong sequence sneak past the tokenizer and get interpreted as a different character downstream?
Stage 2 (parser/pre-parser) — Bug surface: moderate
- Pre-parser / full-parser divergence: the pre-parser skims function bodies, but must correctly track brace matching. A brace-matching bug in the pre-parser can cause a function boundary to be misidentified, leading to incorrect scope chains and potentially uninitialized variable reads in the optimized code.
eval/withscope poisoning: the interaction between lazy compilation and dynamic scopes is subtle. If the pre-parser misses anevalcall inside a nested construct, the function may be compiled with an incomplete scope, and a later dynamically-created variable could alias an existing one.
Stage 3 (bytecode generator) — Bug surface: moderate
- Incorrect feedback slot allocation: if the generator assigns the same feedback slot index to two different bytecodes, the feedback profile for one operation will overwrite the other. The optimizer then generates code based on wrong assumptions — classic type confusion setup.
- Constant pool corruption: if the constant pool index bounds aren’t checked, an
LdaConstant [N]with N out of range reads garbage and feeds it to the JIT.
Stage 4 (Ignition interpreter) — Bug surface: small
- The interpreter is relatively simple C++ code, but bytecode handler dispatch is generated from a DSL. Bugs in the code generator that produces the handlers can cause misinterpretation of bytecode operands.
TurboFan (JIT compiler) — Bug surface: LARGE
This is where the big bounties are. TurboFan has the most attack surface of any V8 component:
| Bug class | How it happens | Example CVEs |
|---|---|---|
| Incorrect lowering | A SpeculativeSafeIntegerAdd is lowered to Int32Add without the proper overflow check. Integer overflow in JIT-code → OOB array access. | CVE-2021-38003 |
| Missing map check | The compiler elides a CheckMaps guard because it “proves” the map is stable — but the proof is wrong. Attacker swaps the object’s map post-optimization. | CVE-2022-1364 |
| Escape analysis bug | An object is marked “does not escape” and stack-allocated, but a missed code path actually lets it escape. Stack pointer into garbage-collected heap → type confusion. | CVE-2021-30517 |
| Bounds check elimination | The compiler proves an index is always in-bounds and removes the CheckBounds — but the proof relies on a 32-bit integer assumption, and a 64-bit value breaks it. | CVE-2023-2033 |
| Load elimination | A redundant load is removed, but the effect chain is wrong — a store that should invalidate the load was on a different effect chain. Stale value is used. | CVE-2020-16040 |
| Node cache collision | Two nodes with different semantics are incorrectly merged (GVN — Global Value Numbering bug). The wrong node is used, producing an incorrect value. | CVE-2021-37975 |
Inline caches — Bug surface: moderate
- Handler Smi overflow/corruption: if an attacker can trigger a store that writes past the end of the feedback vector or corrupts the heap holding feedback data, they control the handler offset. The next property access becomes an arbitrary OOB read/write.
- Transition from monomorphic to megamorphic with corrupted state: if the IC slot’s state word is overwritten mid-transition, the stub might interpret a Smi as a Map pointer or a Map as a handler.
Map transitions / hidden classes — Bug surface: moderate
- Transition array corruption: if a Map’s transition array is overwritten, adding a property might follow a transition to an attacker-controlled Map, assigning properties of attacker-chosen types to the object.
- Descriptor array OOB: if the descriptor count is corrupted to be larger than the actual array, a property load reads from beyond the descriptor array boundary.
Deoptimization — Bug surface: moderate
- TranslationArray corruption: if the translation data that maps optimized frames back to interpreter frames is tampered with, deoptimization will reconstruct a frame with attacker-controlled local variable values.
- Deopt trampoline register clobbering: if the trampoline doesn’t correctly save/restore all registers, a deopt can leave stale values in registers that are then used by the interpreter.
Prototype chain / Map deprecation — Bug surface: small
- Map deprecation races: if a Map is deprecated (a new property is added to the prototype, forcing a shape update) while optimized code is mid-execution, a TOCTOU window exists where the optimized code accesses a property at a stale offset.
Which layer owns which bug?
Tie it together with a map you can keep in your head:
| Layer | You’ll see | Typical bugs |
|---|---|---|
| Language | coercion, prototypes, scope | type-juggling bypasses, prototype pollution |
| Browser host | innerHTML, location, document | DOM XSS, mXSS, open redirect |
| Node host | require, process, child_process | RCE, sandbox escape, SSRF |
| Engine | bytecode, ICs, TurboFan | type confusion, OOB read/write |
And now you can go one level deeper — when you see a V8 CVE, you know which pipeline stage it lives in:
| Pipeline stage | If exploited here, you get |
|---|---|
| Tokenizer / parser | Possibly engine-level RCE if the malformed AST leads to incorrect native code emission (rare). |
| Bytecode generator | Corrupted feedback → JIT generates code with wrong assumptions → type confusion. |
| IC feedback | OOB read/write via handler corruption. |
| TurboFan lowering | Integer overflow, missing bounds checks, type confusion. |
| Deoptimizer | Frame reconstruction with attacker-controlled state → sandbox escape. |
Lab: deep experiments
A note on tooling: These labs use Node.js for accessibility. For actual V8 vulnerability research, use d8 — V8’s own developer shell. It exposes the full
--allow-natives-syntaxsurface (%GetOptimizationStatus(),%OptimizeFunctionOnNextCall(),%DeoptimizeFunction(), etc.) that Node.js partially restricts. Build from source:git clone https://chromium.googlesource.com/v8/v8.git cd v8 tools/dev/gm.py x64.debug out/x64.debug/d8 --allow-natives-syntax your_script.js
Lab 1 — Watch one function change tiers
function price(item) {
return item.cost + 1;
}
const a = { name: "book", cost: 10 };
const b = { name: "pen", cost: 2 };
// Warm up on one stable shape.
for (let i = 0; i < 100000; i++) {
price(a);
price(b);
}
console.log(price(a)); // 11 — number
console.log(price({ cost: "10" })); // "101" — string concatenation!
console.log(price({ cost: { valueOf: () => 9 } })); // 10 — object coerces to 9
Run it and trace the tiers:
node --trace-opt --trace-deopt lab.js
Questions to answer in your own words: Why do a and b share a shape? Why does { cost: "10" } produce "101" instead of 11? What causes the coercion in the last call, and which engine concepts (Map transitions, feedback, ICs, deopt) does this one tiny function touch?
- Check your answers
aandbare built with the same properties in the same order, so they share the same Map via theRootMap → add name → add costtransition chain.{ cost: "10" }produces"101"because the BinaryOp feedback slot (attached to the+in bytecode) was trained on Smi + Smi. When a string operand arrives, the feedback widens — the optimized code’s speculative guard fails and a deopt fires. Back in the interpreter, ECMAScript’s+semantics rule: if either operand is a string,ToStringcoercion runs on the other →"10" + 1→"10" + "1"→"101". The Map of{ cost: "10" }is identical to{ cost: 10 }— Maps track property names and offsets only, not value types. The deopt is a BinaryOp feedback mismatch, not a Map mismatch.- The last object has a
valueOf→object + 1triggersToPrimitive, callsvalueOf(), gets9, does9 + 1 = 10. This touches the IC’s BinaryOp feedback (which now sees a non-primitive operand), ToPrimitive/valueOfcoercion via the spec, plus a likely deopt because the feedback was trained on Smi + Smi. - In ~4 lines you’ve exercised hidden classes, BinaryOp inline-cache feedback, numeric-vs-string
+, object-to-primitive coercion viaSymbol.toPrimitive/valueOf, and (likely) a deopt.
Lab 2 — Dump real Ignition bytecode and read it
function multiplyAndAdd(a, b) {
var tmp = a * b;
return tmp + 1;
}
for (let i = 0; i < 100000; i++) multiplyAndAdd(i, 2);
node --print-bytecode --print-bytecode-filter=multiplyAndAdd lab2.js
Try to identify in the output: (1) the Mul instruction and its feedback slot; (2) the Add instruction; (3) which registers are used for a, b, and tmp. Compare the bytecode before and after optimization by adding --trace-opt.
Lab 3 — Force megamorphic IC and measure the cost
function accessPoint(o) {
return o.x + o.y;
}
// Shape 1
const s1 = { x: 1, y: 2 };
// Shape 2
const s2 = { x: 1, y: 2, z: 3 };
// Shape 3
const s3 = { y: 2, x: 1 };
// Shape 4
const s4 = { x: 1, y: 2, extra: true };
// Shape 5+
// ... keep adding shapes until IC goes megamorphic
const objects = [s1, s2, s3, s4];
// Monomorphic warmup (comment out shapes 2-4 first, then add them back one by one)
for (let i = 0; i < 100000; i++) {
accessPoint(objects[i % objects.length]);
}
Run with --trace-ic to see the IC state transitions:
node --trace-ic lab3.js
Watch the LoadIC for o.x and o.y go from UNINITIALIZED → MONOMORPHIC → POLYMORPHIC → MEGAMORPHIC. Now time the function with performance.now() at each stage to feel the performance cliff.
Lab 4 — Trigger a visible deopt
const { performance } = require('perf_hooks');
function add(a, b) {
return a + b;
}
// Train as int addition.
for (let i = 0; i < 100000; i++) add(i, i + 1);
// Measure optimized speed.
let start = performance.now();
for (let i = 0; i < 10000000; i++) add(i, i + 1);
console.log("Optimized (ints):", performance.now() - start, "ms");
// Deopt: introduce strings.
add("hello", "world");
// Measure deopted speed (runs in interpreter now).
start = performance.now();
for (let i = 0; i < 10000000; i++) add(i, i + 1);
console.log("After deopt:", performance.now() - start, "ms");
The second measurement is often 3-10× slower because add was deoptimized and may not be re-optimized (V8 sees unstable feedback).
Lab 5 — Map transition visualization
// V8's %DebugPrint (requires --allow-natives-syntax)
// Run: node --allow-natives-syntax lab5.js
function debug(obj) {
%DebugPrint(obj);
}
const a = {};
debug(a);
a.name = "Ava";
debug(a);
a.role = "admin";
debug(a);
// Now create an object with the same properties but in reverse order:
const b = {};
b.role = "guest";
debug(b); // Different Map from a.role step!
b.name = "Mina";
debug(b); // Different Map from a's final form!
Run:
node --allow-natives-syntax lab5.js
Look at the Map addresses in the output. Objects with the same structure in the same order share a Map address. Different order → different Map.
V8 Source Navigation
When you want to read the actual code that implements all of this:
| What you want to find | V8 source path |
|---|---|
| Scanner / tokenizer | src/parsing/scanner.h, src/parsing/scanner.cc |
| Parser / AST | src/parsing/parser.cc, src/ast/ast.h |
| Pre-parser (lazy) | src/parsing/preparser.h, src/parsing/preparser.cc |
| Bytecode generator | src/interpreter/bytecode-generator.cc |
| Bytecode definitions | src/interpreter/bytecodes.h |
| Ignition interpreter | src/interpreter/interpreter.cc, src/interpreter/interpreter-generator.cc |
| Feedback vector | src/objects/feedback-vector.h, src/objects/feedback-vector.cc |
| Inline Cache (IC) | src/ic/ic.h, src/ic/ic.cc, src/ic/accessor-assembler.cc |
| Maps (hidden classes) | src/objects/map.h, src/objects/map.cc, src/objects/map-updater.cc |
| TurboFan pipeline | src/compiler/pipeline.cc |
| Sea of nodes / IR | src/compiler/node.h, src/compiler/graph.h, src/compiler/opcodes.h |
| TurboFan optimization phases | src/compiler/ (reducers: js-typed-lowering.cc, simplified-lowering.cc, load-elimination.cc, escape-analysis.cc, etc.) |
| Machine code generation | src/compiler/backend/ (instruction selection, register allocation, code generation) |
| Deoptimizer | src/deoptimizer/deoptimizer.cc, src/deoptimizer/translation-array.cc |
| Object representation | src/objects/objects.h, src/objects/js-objects.h |
Clone the V8 repo and start grepping:
git clone https://chromium.googlesource.com/v8/v8.git
cd v8
rg "class Map " src/objects/map.h
rg "void Deoptimizer::" src/deoptimizer/
rg "SpeculativeNumberMultiply" src/compiler/
Glossary
| Term | Meaning |
|---|---|
| AST | Abstract Syntax Tree — the structured representation of parsed source code. |
| Bytecode | Linear, low-level instructions executed by the Ignition interpreter. |
| Inline cache (IC) | Per-site feedback stored in the feedback vector; records what Maps and handlers a property access has seen. |
| Hidden class / Map | Metadata describing an object’s property layout and the transitions that created it. |
| Type feedback | Runtime record of the types, Maps, and call targets a bytecode instruction has encountered. |
| Feedback vector | A FixedArray attached to each JSFunction that stores all IC feedback slots. |
| Ignition | V8’s register-based bytecode interpreter with an accumulator architecture. |
| TurboFan | V8’s optimizing JIT compiler — consumes bytecode + feedback, produces native machine code. |
| Sea of nodes | TurboFan’s intermediate representation: a single graph with data, effect, and control edges. |
| Smi | ”Small integer” — a 31/32-bit signed integer stored directly in a tagged value without heap allocation. |
| HeapNumber | A heap-allocated boxed double; used when a numeric value doesn’t fit in Smi range. |
| Tagged value | A 64-bit value where the LSB indicates type: 0 = Smi, 1 = heap pointer. |
| NaN-boxing | Value encoding used by SpiderMonkey and JSC; non-double values are stored in the NaN space of IEEE 754. |
| Pointer compression | V8 optimization that stores heap pointers as 32-bit offsets from a 4 GB cage base. |
| Map transition tree | The directed graph of Maps linked by property additions; construction order determines the path. |
| Descriptors | The array of property names and attributes attached to a Map. |
| Dictionary mode | Slow object representation (NameDictionary) used after delete or excessive property additions. |
| Elements kind | How array-indexed properties are stored — ranges from PACKED_SMI_ELEMENTS (fastest) to DICTIONARY_ELEMENTS (slowest). |
| Host | The environment that embeds the engine and adds host-specific APIs (browser, Node, Deno). |
| Engine | The program that implements JavaScript — parses, compiles, runs (V8, SpiderMonkey, JSC). |
| Deoptimization | Bailing out of optimized code when a speculative assumption fails; includes full interpreter frame reconstruction. |
| TranslationArray | Data structure that maps optimized frame state to interpreter frame state; used by the deoptimizer. |
| Safepoint | A location in optimized code where the deoptimizer can reconstruct the interpreter state. |
| Code dependency | A recorded assumption (e.g., “Map X is stable”) that, when invalidated, triggers eager deoptimization. |
| Lazy parsing | Deferred full parsing of a function body until first invocation; only syntax-checked by the pre-parser. |
| OSR (On-Stack Replacement) | Swapping a running function from interpreter to optimized code mid-execution, typically at a loop header. |
| Type confusion | Treating a value as the wrong type — a Smi as a pointer or vice versa. The root of most engine RCE exploits. |
| OOB read/write | Out-of-bounds memory access — reading or writing beyond an object’s or array’s allocated memory. Typically built from a type confusion primitive. |
Next in the series: Episode 3 — Scanning and Parsing. We’ll dissect the scanner state machine, the precedence-climbing expression parser, automatic semicolon insertion, and what happens when source text is syntactically valid but semantically wrong.