Chapter 2.1☕ 25 min read

JS Engine Architecture: AST, Bytecode & JIT

Code likhte ho tum — machine code banata hai V8. Andar kya hota hai? Aaj dekho.

01Lexing & Tokens

V8 receives your JavaScript as a UTF-16 string. The first thing it does is break this string into tokens — the fundamental building blocks of the language.

The Lexer (Scanner) reads the source character by character and produces tokens. Each token has a type and a value:

// Source string V8 receives:
// "const x = 1 + 2;"

// Tokens produced by scanner:
// [
//   { type: 'KEYWORD',     value: 'const' },
//   { type: 'IDENTIFIER',  value: 'x'     },
//   { type: 'PUNCTUATOR',  value: '='     },
//   { type: 'NUMBER',      value: 1       },
//   { type: 'PUNCTUATOR',  value: '+'     },
//   { type: 'NUMBER',      value: 2       },
//   { type: 'PUNCTUATOR',  value: ';'     },
// ]

Whitespace, newlines, and comments are discarded at this stage — they're not tokens. The scanner runs in one pass, O(n) in source length.

Contextual keywords like let, async, of are valid identifiers in some positions (like property names) but keywords in others. The scanner handles this context-sensitivity.

V8's scanner is hand-written C++ — not a generated lexer (like Lex/Yacc would produce). It's optimized for JavaScript's quirks: automatic semicolon insertion, regex vs division ambiguity (/a/ vs 1/a/g), and template literal scanning. These context-sensitive cases are handled manually for maximum speed.
02AST Construction

The Parser takes the token stream and builds an AST (Abstract Syntax Tree) — a tree structure that represents the structure of your code, not the raw text.

Each node in the AST has a type and children. For const x = 1 + 2;:

// "const x = 1 + 2;" becomes this AST:

// VariableDeclaration (kind: "const")
//   └── VariableDeclarator
//         ├── Identifier (name: "x")
//         └── BinaryExpression (operator: "+")
//               ├── NumericLiteral (value: 1)
//               └── NumericLiteral (value: 2)

// You can see this yourself:
// 1. Open https://astexplorer.net
// 2. Paste any JS code
// 3. See the AST V8 roughly builds

V8 uses two parsers for performance:

Eager parser: Fully parses code — builds complete AST. Used for top-level code and functions that are immediately invoked.

Lazy parser (pre-parser): Only checks syntax validity, doesn't build full AST for function bodies. 2-3x faster than eager parsing. Full parse happens only when the function is actually called.

📋 Pro Tip:
V8's lazy parsing is why wrapping code in IIFE ((function(){...})()) used to be slow — it forces eager parsing. Modern V8 is smarter, but the principle holds: code inside functions that are NOT called immediately gets lazy-parsed first, saving time on startup.
03Ignition Bytecode

The AST is handed to Ignition — V8's bytecode interpreter. Ignition compiles the AST to bytecode, a compact, platform-independent instruction set.

Key properties of V8 bytecode:

  • Each instruction is 1-4 bytes (vs x64 machine code: 1-15 bytes)
  • Uses virtual registers: accumulator (implicit result), r0, r1, r2...
  • Common instructions: Ldar (Load into accumulator), Star (Store from accumulator), Add, Call, Return
// function add(a, b) { return a + b; }

// Ignition bytecode (simplified):
// Ldar a        // Load parameter 'a' into accumulator
// Add b, [0]    // Add parameter 'b' to accumulator
// Return        // Return accumulator value

// You can see real V8 bytecode with:
// node --print-bytecode yourfile.js

// Example output:
// [generated bytecode for function: add]
// Parameter count 3
// Register count 0
//          Ldar a0
//          Add a1, [0]
//          Return

First execution is ALWAYS bytecode — no machine code yet. Ignition runs the bytecode through its interpreter loop.

Bytecode is 25-50% smaller than machine code for the same logic. This matters because JS is downloaded from the network — smaller bytecode = faster startup. V8 also caches bytecode to disk (script streaming) so the same script doesn't need to be re-compiled on next visit.
04TurboFan JIT

TurboFan is V8's optimizing JIT (Just-In-Time) compiler. It takes hot bytecode and compiles it to optimized machine code specific to your CPU.

How it works:

  1. Ignition executes bytecode and collects type feedback (what types it sees)
  2. When a function is called ~1000-2000 times, it becomes "hot"
  3. TurboFan kicks in, reads the type feedback, and generates optimized machine code

Type specialization is the key optimization: if add() always receives numbers, TurboFan generates machine code only for numbers — no type checks needed.

function add(a, b) { return a + b; }

// After ~1000 calls with numbers:
add(1, 2); // → Ignition bytecode
add(3, 4); // → still bytecode
// ... 998 more calls ...
add(5, 6); // → TurboFan kicks in!
// Now compiled to optimized machine code:
// MOV rax, [a]     ; load a
// ADD rax, [b]     ; add b
// RET              ; return (no type checks needed!)

// But then:
add('hello', 'world'); // ← String! Type assumption broken!
// → DEOPTIMIZATION — fall back to bytecode
// → TurboFan recompiles with broader type assumption
TurboFan doesn't just inline type checks — it generates SIMD instructions for numeric loops, eliminates bounds checks on arrays it proves are safe, and inlines small functions entirely. This is why a tight numeric loop in JS can approach C++ speed.
05Deoptimization

Deoptimization is when TurboFan's type assumption is violated — it must bail out to bytecode.

Common triggers:

  • Changing property types on an object
  • Adding or deleting properties after creation
  • Passing wrong types to a function

Deoptimization is expensive: throw away optimized code, rebuild feedback, potentially re-optimize later.

// ❌ Polymorphic — slows down TurboFan
function process(x) { return x + 1; }
process(1);       // number
process('hello'); // string — now polymorphic!
process(true);    // boolean — megamorphic!

// ✅ Monomorphic — TurboFan loves this
function addNumbers(a, b) { return a + b; }
addNumbers(1, 2);
addNumbers(3, 4);
addNumbers(5, 6); // Always numbers — one hidden class

// ❌ Shape change after creation — causes deopt
const obj = { x: 1 };
obj.y = 2;       // Shape changes — new hidden class!
obj.z = 3;       // Another new hidden class!

// ✅ Define all properties upfront
const obj = { x: 1, y: 2, z: 3 }; // One hidden class, stays stable

Polymorphism levels:

  • Monomorphic (1 type) = fully optimized
  • Polymorphic (2-4 types) = partial optimization
  • Megamorphic (5+ types) = no optimization, slow path
📋 Pro Tip:
You don't need to obsess over V8 internals for every line of code. But in hot paths (loops called millions of times, render functions, event handlers), keeping types consistent and object shapes stable can give 10-50x speedups. Use node --trace-opt --trace-deopt yourfile.js to see what V8 is doing.

Lo kar liya — Key Points:

  • ✅ Source code → Tokens (lexer) → AST (parser) → Bytecode (Ignition) → Machine code (TurboFan) — that is the full V8 pipeline
  • ✅ Lazy parsing skips function bodies on first pass — only fully parsed when the function is actually called
  • ✅ Ignition bytecode runs immediately — TurboFan only kicks in after a function is called ~1000+ times
  • ✅ TurboFan speculates on types — if add() always sees numbers, it generates number-only machine code
  • ✅ Deoptimization happens when the type assumption breaks — expensive, causes recompilation
  • ✅ Keep types consistent and object shapes stable — this is the single most impactful V8 optimization tip
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase