Free lesson · JavaScript Basic to Advance
Parsing, AST, and Bytecode
From Source Text to Executable
Before V8 can run any code, it has to convert source text into something executable. This conversion happens in stages, and each stage produces a representation that's progressively closer to what the CPU actually does.
// You write:
function add(a, b) {
return a + b;
}
add(2, 3);
V8 doesn't directly execute these characters. It transforms them through several intermediate representations:
- Tokens — the source text broken into meaningful chunks (
function,add,(,a,,,b,), etc.) - AST — a tree structure representing the program's logical organization
- Bytecode — compact instructions Ignition can execute directly
- Machine code — eventually, for hot functions, native CPU instructions (covered in Topic 13.3)
This Topic walks through each stage at a level useful for understanding what's happening. Implementation details vary; the conceptual stages are common across all modern engines.
Stage 1: Tokenization (Lexing)
The first transformation breaks the source into tokens — atomic chunks that the parser will recognize.
Source: function add(a, b) { return a + b; }
Tokens: [FUNCTION] [IDENT "add"] [LPAREN] [IDENT "a"]
[COMMA] [IDENT "b"] [RPAREN] [LBRACE] [RETURN]
[IDENT "a"] [PLUS] [IDENT "b"] [SEMICOLON] [RBRACE]
Tokenization is mechanical. It doesn't understand structure — it just classifies each chunk of text. Keywords, identifiers, operators, literals, punctuation, whitespace (usually skipped). Tokens have types and values.
A few things tokenization handles that look simple but aren't:
Automatic semicolon insertion. JavaScript lets you omit some semicolons. The tokenizer/parser has rules for when a semicolon is implicit. This is also where the famous "return + newline" bug comes from:
function broken() {
return
{
value: 42
};
}
console.log(broken()); // undefined, not {value: 42}
// JS inserts a semicolon after 'return', returning early
Regex vs division. The / character can mean division or the start of a regex literal. The tokenizer has to use context to decide which.
Template literals. Backtick strings can contain ${ ... } expressions. The tokenizer has to switch between string mode and expression mode mid-string.
These edge cases make tokenization more complex than it sounds, but at a high level it's straightforward: text in, classified tokens out.
Interactive animation: Text becomes tokens, then a tree: the parser freezes operator precedence into the shape of the AST.
Stage 2: Parsing — Building the AST
Tokens go into a parser, which builds an Abstract Syntax Tree (AST) — a tree representation of the program's structure.
For function add(a, b) { return a + b; }, the AST conceptually looks like:
FunctionDeclaration
├── name: "add"
├── params: [Identifier "a", Identifier "b"]
└── body: BlockStatement
└── statements:
└── ReturnStatement
└── argument: BinaryExpression
├── operator: "+"
├── left: Identifier "a"
└── right: Identifier "b"
The tree captures the program's logical structure. Function declarations have names and bodies. Bodies contain statements. Expressions have operators and operands. The parser turns flat tokens into nested structure.
You can see the AST for any JavaScript code using tools like AST Explorer (astexplorer.net) — a useful site for understanding parsing. Babel, TypeScript, ESLint, and Prettier all work on ASTs internally.
graph TD
A[FunctionDeclaration: add] --> B[Identifier: a]
A --> C[Identifier: b]
A --> D[BlockStatement]
D --> E[ReturnStatement]
E --> F[BinaryExpression: +]
F --> G[Identifier: a]
F --> H[Identifier: b]
style A fill:#7c3aed,color:#fff
style F fill:#10b981,color:#fff
This diagram shows the AST structure for a simple function. The top-level node represents the function declaration with its name. It has child nodes for parameters a and b, and a body containing a single statement. The return statement contains a binary expression — the plus operator with left operand a and right operand b. This nested structure makes the program's logic explicit.
Lazy Parsing
A clever optimization V8 uses: lazy parsing. Instead of parsing every function fully on first load, V8 does a quick pre-parse to find function boundaries, then defers full parsing of each function until it's actually called.
// V8 pre-parses this to find that 'maybeUsed' is a function with this signature
function maybeUsed(a, b) {
// V8 doesn't fully parse this body unless maybeUsed is called
return complexExpression(a, b);
}
// V8 fully parses 'definitelyUsed' before running it
definitelyUsed();
function definitelyUsed() {
// body is fully parsed here
}
For a large bundle with many unused functions, lazy parsing significantly reduces startup cost. The pre-parse is fast (it just finds boundaries); the full parse only happens when needed.
This is also why startup performance is sensitive to the structure of your code. A module with one giant function that runs immediately pays more parsing cost upfront than a module with many small functions where most aren't called.
Interactive animation: Pre-parse now, full-parse on call - and why bundles wrap functions that run immediately in parens.
Stage 3: AST to Bytecode
V8 doesn't execute the AST directly. It converts the AST to bytecode — a compact, register-based instruction set that Ignition can execute efficiently.
Conceptually, bytecode for a + b looks like:
Ldar a // load register from variable 'a'
Add b // add value of 'b' to accumulator
Return // return the accumulator
This is a simplified rendering. Real V8 bytecode has hundreds of opcodes covering everything from arithmetic to property access to function calls. The full set is called Ignition bytecode.
Bytecode has several advantages over executing the AST directly:
Smaller. Bytecode is more compact than AST. Less memory pressure, better cache behavior.
Faster to execute. Bytecode interpretation is a tight loop over opcodes. AST execution would require walking trees, which is slower.
Easier to optimize. Bytecode is closer to machine code structurally. Hot bytecode paths can be analyzed and compiled to machine code more easily than ASTs.
Ignition is V8's bytecode interpreter. Every piece of JavaScript starts running through Ignition. Only after Ignition has executed a function many times does V8 decide it's worth compiling.
Interactive animation: What add compiles down to: three Ignition instructions - Ldar, Add, Return - as node --print-bytecode shows them.
Why Two Compilers Exist
A subtle V8 design decision: there are actually three execution tiers, not two.
Ignition. Interprets bytecode. Cheap to set up (no compilation), expensive to run (per-instruction overhead).
Sparkplug. A non-optimizing compiler that converts bytecode to machine code quickly. Faster than interpretation, cheaper than full optimization. Added in 2021 to provide a middle tier.
TurboFan. The optimizing compiler. Produces highly-optimized machine code for hot functions. Expensive compilation, very fast execution.
The progression for a typical function:
- First call: parse, generate bytecode, execute in Ignition.
- Several calls later: V8 notices this function is being used a lot. Sparkplug compiles it to machine code.
- Many more calls: V8 collects type information and decides to optimize further. TurboFan produces optimized machine code based on the observed types.
- If types change: TurboFan-optimized code is invalidated. Execution falls back to Sparkplug or Ignition.
This tiered approach trades off compilation cost against execution speed. Code that runs once stays interpreted. Code that runs many times gets compiled. Code that runs millions of times gets fully optimized.
What This Means in Practice
A few practical observations from this pipeline:
Cold start cost matters. Loading a large JavaScript bundle has a real cost: tokenizing, parsing, generating bytecode. Bundle size affects startup time, even if all the code never runs.
Hot code path identification is implicit. V8 figures out what's hot by counting executions. You don't tell it. Functions called in tight loops, in render code, in event handlers get optimized automatically.
Optimization isn't free. Compiling to machine code costs time. Functions called rarely don't recoup that cost. This is why V8's tiered approach exists — interpretation is the right default for cold code.
Deoptimization is real. When TurboFan's assumptions about types break (Topic 13.3 covers this), it has to throw away the optimized code and fall back. Frequent deoptimization is bad for performance. Stable types help V8 keep optimizing.
A Side Note on TypeScript and Babel
A common confusion: TypeScript, Babel, and similar tools also work with ASTs. But they're different from V8's pipeline.
TypeScript and Babel are source-to-source transformers. They read JavaScript (or TypeScript), produce an AST, transform it, and emit JavaScript again. They run at build time, not runtime. The JavaScript they emit then goes through V8's pipeline normally.
V8's parser and bytecode generator run at runtime, inside the engine, on whatever JavaScript you give it. The two systems don't share code or representations. They just happen to both work with ASTs because ASTs are the natural representation for code transformations.
Common Pitfalls
Warning: The parsing and bytecode traps.
Assuming all engines have the same intermediate representations. V8 has Ignition bytecode. SpiderMonkey has its own bytecode format. JavaScriptCore has yet another. Performance optimizations targeting V8's specific bytecode patterns may not apply elsewhere.
Worrying about parsing performance for application code. For typical web apps and Node services, parsing is rarely the bottleneck. Optimize what's actually slow, not what theoretically could be slow.
Confusing TypeScript's AST tools with V8's parser. They're separate. Optimizations that affect TypeScript compilation don't affect runtime performance, and vice versa.
Why This Matters in Interviews
Interview: Parsing and bytecode questions are deep staff-level territory. They come up when interviewers want to test whether you understand what's happening before JIT compilation kicks in. Knowing that JavaScript goes through tokenization, parsing, and bytecode generation before any optimization happens is the foundation for understanding the rest of engine performance.
Topic 13.7 covers the interview-prep treatment.
Try It
Exercise: Visit astexplorer.net and paste in a simple function. Explore the AST. Try:
- A function declaration — see the FunctionDeclaration node
- An arrow function — note how it differs from a function declaration
- A class with methods — see how methods are represented
- A destructuring assignment — see how the LHS is represented
Then try Node.js with
--print-bytecode:node --print-bytecode --print-bytecode-filter=myFunction script.jsCompare what you see with the AST. The bytecode is what Ignition actually executes — you'll see opcodes like Ldar, Star, Add, Return. Don't try to memorize them. The point is to see that there's a real, structured intermediate representation underneath the source text.