Compiler Algorithms
A tokenizer is a state machine, a parser is recursion with a precedence table, an optimiser is graph rewrites to a fixpoint, a register allocator is interval scheduling, and tree shaking is reachability. This part is each, small enough to write, with where it runs in your toolchain and where it runs again in the engine.
Tokenising as a state machine
"A tokenizer, a parser, an optimiser. What are the algorithms, and which ones does my bundler actually run?"
All of them, on every build. A tokenizer is a finite state machine over characters; a parser is recursion (or a precedence table) over tokens; an optimiser is a sequence of rewrites over a graph until nothing changes; a register allocator is interval scheduling; tree shaking is reachability. The toolchain between your source and the browser (TypeScript, Babel or SWC, esbuild or Rollup, terser) runs the first and last three; the engine runs all of them again at load (the JS course, parts 1 and 4). This part is each algorithm, small enough to write.
// a tokenizer as a table-driven state machine (the tiny browser's HTML tokenizer is the switch-statement form of this)
const STATES = { start: 0, ident: 1, number: 2, string: 3, op: 4 }
const classify = ch => /[a-zA-Z_$]/.test(ch) ? 'alpha' : /[0-9]/.test(ch) ? 'digit' : ch === '"' ? 'quote' : /\s/.test(ch) ? 'space' : 'other'
// transition table: state × class → [nextState, action] (action: 'start' a token, 'append', 'emit' and re-read, 'skip')
const T = {
0: { alpha: [1, 'start'], digit: [2, 'start'], quote: [3, 'start'], space: [0, 'skip'], other: [4, 'start'] },
1: { alpha: [1, 'append'], digit: [1, 'append'], quote: [0, 'emit'], space: [0, 'emit'], other: [0, 'emit'] },
2: { digit: [2, 'append'], alpha: [0, 'emit'], quote: [0, 'emit'], space: [0, 'emit'], other: [0, 'emit'] },
3: { quote: [0, 'emitInclusive'], alpha: [3, 'append'], digit: [3, 'append'], space: [3, 'append'], other: [3, 'append'] },
4: { alpha: [0, 'emit'], digit: [0, 'emit'], quote: [0, 'emit'], space: [0, 'emit'], other: [4, 'append'] },
}
function* tokenize(src) {
let state = 0, buf = ''
for (let i = 0; i <= src.length; i++) {
const ch = i < src.length ? src[i] : ' ', [next, action] = T[state][classify(ch)]
if (action === 'start') buf = ch; else if (action === 'append') buf += ch
else if (action === 'emit') { yield { state, text: buf }; buf = ''; i-- } // re-read ch in the start state
else if (action === 'emitInclusive') { yield { state, text: buf + ch }; buf = '' }
state = next
}
}
// O(n): one table lookup per character; no backtracking. a regex-based lexer (/\w+|\d+|"[^"]*"|./gy) is the same automaton compiled by the regex engine.
// keywords: check the identifier against a Set after emitting (V8: a perfect hash on length + first chars). the slash ambiguity (/ vs regex):
// the parser tells the tokenizer which to expect (the JS course part 1)- States are "what kind of token am I in the middle of"; inputs are character classes; transitions are a table or a switch; actions accumulate characters and emit tokens.
- Deterministic, no backtracking: one lookup per character; O(n). The longest-match rule (an identifier continues as long as identifier characters continue) is encoded by staying in the state.
- The table form is what lexer generators (lex, re2c) and regex engines compile to: a DFA. The switch form (the tiny browser's HTML tokenizer, V8's scanner) is the same automaton hand-written, usually faster because the hot transitions are specialised.
- Context dependence breaks the pure model: JavaScript's
/(division or regex) and template literals need the parser to set the tokenizer's mode (the JS course part 1). HTML's<script>content switches the tokenizer to raw text until the matching end tag (the browser course part 2).
- Throughput: tens to hundreds of MB/s for a hand-written scanner; the whole build-time pipeline is tokenizer-bound for large codebases, which is why esbuild and SWC rewrote it in Go and Rust.
- Interning: emitting identifiers as pointers into a string table (the JS course part 6) makes every later comparison O(1).
- Streaming: a tokenizer that yields tokens as bytes arrive lets parsing overlap download (the browser course part 2).
Parsing: recursive descent and Pratt
A parser turns tokens into a tree. For statements and declarations, recursive descent (one function per grammar rule, calling each other) is the natural fit and what every production JavaScript parser uses. For expressions, where precedence and associativity would need a function per level, Pratt parsing (operator-precedence with binding powers) handles every binary and unary operator in one loop.
- One function per non-terminal:
parseStatementlooks at the current token and dispatches toparseIf,parseFor,parseVariableDeclaration, …, each of which consumes its tokens and calls the functions for its parts. - Lookahead: usually one token (LL(1)); a few places need two (
async functionversusasyncas an identifier;(a, b) =>versus a parenthesised expression, which V8 handles with a "cover grammar" that parses as an expression and reinterprets). - Error reporting: the failing function knows exactly what it expected ("expected ; after expression"), which is why hand-written parsers give better messages than generated ones.
- Complexity: O(n) for LL(1) grammars; each token is consumed once. Backtracking (trying one rule, failing, trying another) can make it exponential; production parsers avoid it with lookahead and cover grammars.
- Binding powers per operator: a left and a right number.
parseExpr(minBP)parses a prefix (a literal, an identifier, a unary operator applied toparseExpr(prefixBP), a parenthesisedparseExpr(0)), then loops: while the next token is an infix operator with left power aboveminBP, consume it and parse its right operand withparseExpr(rightBP), building a node. - Precedence is the magnitude: higher binds tighter. Associativity is the relation between left and right power: left < right for left-associative (
a - b - cis(a - b) - c), left > right for right-associative (a ** b ** c, assignment, the conditional operator). - Postfix and mixfix: postfix operators have a left power and no right side; calls, indexing and the ternary are infix operators whose handler parses up to a closing token.
- Same complexity, far less code: O(n); a table of numbers and three handler kinds instead of a function per precedence level. V8, TypeScript, Babel, Acorn, and esbuild all use precedence climbing or Pratt for expressions.
SSA, folding, and dead code
Optimising compilers work on an intermediate representation where every variable has exactly one definition. That property, static single assignment, makes the questions an optimiser asks (what is this value, is it used, can it move) answerable by following pointers rather than by analysing control flow. Three passes on SSA, iterated, do most of the work: constant propagation and folding, dead-code elimination, and inlining.
- Rename: each assignment to
xbecomes a new name (x1,x2, …); each use refers to the name reaching it. - φ functions: where two control paths merge (after an
if, at a loop header), aφ(x1, x2)selects by which predecessor was taken. Placed at dominance frontiers (the standard algorithm computes the dominator tree, then the frontiers, then inserts φs for each variable, then renames in a tree walk). O(n) in practice. - Sea of nodes (TurboFan): SSA with no fixed instruction order: nodes are values, edges are data and control dependencies, and a scheduler decides order at the end. Rewrites become local graph edits.
- Constant propagation and folding: a lattice per value (unknown → constant → not-constant); propagate constants along uses; fold operations on constants at compile time; at φ, the meet of the inputs. Sparse conditional variants also resolve branches on constant conditions and skip unreachable blocks.
- Dead-code elimination: a definition with no uses and no side effects is removed; removing it may orphan its inputs; a worklist re-examines them. Side effects (calls, stores, throws) anchor code; purity annotations let tools remove calls they cannot see into.
- Inlining: replace a call with the callee's body (renamed), then re-run folding and DCE, which now see across the former boundary. Budgeted by size; the single most important enabler of the other passes.
- Common subexpression elimination / global value numbering: identical pure computations are computed once; on SSA, "identical" is "same operator, same input nodes", a hash lookup.
- Loop-invariant code motion: a computation whose inputs are defined outside the loop is moved before it.
- The loop: run the passes until a fixpoint; cap the iterations by a budget. Each pass is O(nodes + edges).
- Minifiers (terser, esbuild, SWC) run folding and DCE on the AST:
if (false) {…}disappears;const DEBUG = false; if (DEBUG)disappears after propagation; unused functions go. - TurboFan runs all of the above on the graph with type feedback as extra constants ("this value is always a Smi"), which is what makes speculation an optimisation and not a guess (the JS course part 4).
- The React Compiler builds an SSA-form HIR of a component, computes which values depend on which inputs, and emits memoisation so that unchanged inputs skip recomputation: dependency analysis on SSA.
- Bundlers run DCE at module granularity: tree shaking (chapter 5).
Register allocation: interval scheduling
// linear scan register allocation (Poletto & Sarkar), the algorithm behind most JIT backends including V8's (with splitting)
// input: live intervals per SSA value: [start, end] in instruction order. k physical registers.
function linearScan(intervals, k) {
intervals.sort((a, b) => a.start - b.start)
const active = [] // intervals currently holding a register, sorted by end
const free = [...REGISTERS] // k of them
for (const iv of intervals) {
for (let j = 0; j < active.length; ) { // expire old intervals: free their registers
if (active[j].end < iv.start) { free.push(active[j].reg); active.splice(j, 1) } else j++
}
if (free.length === 0) { // spill: the interval that ends last gives up its register
const last = active[active.length - 1]
if (last.end > iv.end) { iv.reg = last.reg; last.reg = null; last.spilled = true; active.pop(); insertByEnd(active, iv) }
else iv.spilled = true
} else { iv.reg = free.pop(); insertByEnd(active, iv) }
}
}
// O(n log n) for n intervals (the sort; active is small). vs graph colouring: O(n²) to build the interference graph. JITs cannot afford that.
// a spilled value lives on the stack and is reloaded around uses; V8's allocator splits intervals at use points so a value is spilled only
// across the gap where it is not needed. what you see: --print-opt-code shows mov to/from [rbp-N] slots where pressure was high;
// functions with many simultaneously-live values (big destructurings, wide expressions) spill more.- Values versus registers: optimised code has thousands of SSA values and a machine has 16 general-purpose registers (x64) or 31 (arm64). Values that are live at the same time need different registers; values whose lifetimes do not overlap can share one.
- Live intervals: for each value, the range of instruction positions from its definition to its last use. Computed by a liveness analysis (backwards over the control flow graph: a value is live at a point if some path from there uses it before redefining it).
- The optimal version is graph colouring: build an interference graph (an edge between any two values live at the same time), colour it with k colours. NP-hard in general; heuristics (Chaitin-Briggs) are O(n²) to build the graph. Too slow for a JIT compiling at runtime.
- Sort intervals by start. Walk them in order with an active set (intervals holding a register, ordered by end).
- Expire: before placing an interval, free the registers of active intervals that ended before it starts.
- Allocate: if a register is free, take it. If not, spill: either the new interval or the active one that ends last (whichever ends later goes to the stack), so that the register serves the shorter-lived value.
- Complexity: O(n log n) for the sort, plus O(n × k) for the active set operations with k registers. Fast enough to run on every optimised compile.
- Refinements in V8: interval splitting (spill a value only across the gap where it is unused, reload before the next use), use-position weights (a value used in a loop is costlier to spill), fixed registers for calling conventions, and a separate allocation for floating-point registers. The "mid-tier" allocator used by Maglev is simpler and faster; TurboFan's is the full version.
- Spills in hot loops: a loop body with many simultaneously live values (a wide expression, many locals, a big destructuring) exceeds the register count; the allocator spills to stack slots; the loop has loads and stores it would not otherwise need. Visible in
--print-opt-codeasmov [rbp-0x..], reginside the loop. - Fewer live values, better code: computing values just before use, not holding many partial results, and keeping hot loops small are the source-level levers.
- The same algorithm elsewhere: interval scheduling is also meeting-room assignment, channel allocation, and the heap-free version of "how many rows are visible at once" in a timeline UI (sort by start, sweep, count overlaps).
Tree shaking as reachability
A bundler decides what code ships by marking what is reachable from the entry points along static import edges, at the granularity of individual exports, and dropping the rest. It is the same mark phase as a garbage collector (part 6), over a module graph instead of a heap, with "side effects" playing the role of roots the walker cannot see past.
- Build the graph: parse every module reachable from the entries (the JS course part 11's construction phase, at build time); record each module's imports (which exports of which modules), exports, and re-exports.
- Mark: from each entry's top-level code, mark the imported bindings it references; for each marked export, scan its defining code for references to other imported bindings; mark those; recurse. Export-level granularity means
import { a } from './util'marksaand the thingsa's body references, notutil's other exports. - Side effects: a module whose top level does something observable (calls, assignments to globals, DOM access, class definitions with static side effects) must be kept if it is imported at all, whether or not its exports are used. Bundlers assume side effects unless the package declares
"sideEffects": false(or a list of files), or a call is annotated/*#__PURE__*/. - Sweep: unmarked exports are removed from their modules; modules with no marked exports and no side effects are removed, along with dependencies only they reached.
- Then: scope hoisting (modules concatenated into one scope, bindings renamed), minification (symbol-level DCE and folding: chapter 3), and chunk splitting at dynamic import boundaries (each chunk gets its own mark from its entry).
- CommonJS:
requireis a call, not a static edge;module.exportsis one object. The whole module is one node; nothing inside can be dropped. - Dynamic access:
lib[name],import * as nsfollowed byns[key]: every export becomes reachable. - Top-level side effects without declarations: a module that registers itself, polyfills, or builds a table at load is kept whole. Libraries fix this with
sideEffects: falseand by moving registration into exported functions. - Barrel files (
index.jsre-exporting everything): resolvable, but each re-export is an edge to parse; deep barrels make the graph large and, with side-effectful modules behind them, keep everything. Import from the leaf module. - Classes and objects with unused methods: export-level granularity cannot drop a method from a class that is used; property-level DCE is a minifier job and rarely safe.
npx esbuild --analyze, rollup-plugin-visualizer, webpack-bundle-analyzer: what is in the bundle and the import chain that kept it. A module you expected gone is in the chain for one of the four reasons above; the visualiser names the edge.The compiler pipeline you run every day
| Stage | Algorithm | Complexity | In your toolchain | In the engine |
|---|---|---|---|---|
| Tokenise | Finite state machine; interning | O(n) | TypeScript, Babel, SWC, esbuild, terser: each has one | V8 scanner (JS course P1) |
| Parse | Recursive descent + Pratt / precedence climbing; cover grammars for ambiguity | O(n) with bounded lookahead | Same tools; TypeScript's parser is error-tolerant for the IDE | V8 parser + pre-parser (P1) |
| Type check | Constraint solving over a type lattice; structural subtyping; inference by unification-like flow | Super-linear in the worst case (generics); incremental by file | tsc (the TS course) | None (types erased); feedback-driven specialisation instead (P2 to P4) |
| Transform | AST rewrites (visitor pattern); scope tracking for renames | O(nodes) per plugin | Babel plugins, SWC, TypeScript emit, JSX, React Compiler | Bytecode generation (P2) |
| Link modules | Graph construction and reachability | O(V + E) | Bundlers: resolution, tree shaking, chunking | Module linking (P11) |
| Optimise | SSA; folding; DCE; inlining; GVN; LICM; escape analysis | O(nodes + edges) per pass, iterated under a budget | Minifiers (AST-level); React Compiler (HIR) | Maglev, TurboFan (P4) |
| Allocate registers | Linear scan with splitting | O(n log n) | None (output is JavaScript) | TurboFan backend |
| Emit | Code generation with source maps (a VLQ-encoded mapping per generated position) | O(output) | Every tool; source maps compose across stages | Machine code with safepoint and deopt tables (P4) |
| Cache | Content hashing; persistent caches keyed by inputs | O(inputs) hash | Vite dep cache, Turbopack, tsc incremental | Code cache (P1) |
- Parsing dominates cold builds; it is linear but the constant is large and every tool parses separately (TypeScript, then Babel, then the bundler, then the minifier) unless a single tool does several stages (esbuild, SWC-based pipelines).
- Type checking is the super-linear stage; it is why
tscis run separately from bundling (transpileOnly,isolatedModules) and incrementally. - Tree shaking and chunking are cheap once the graph exists.
- Minification is a second parse and a few passes; terser is slow (JavaScript), esbuild and SWC fast.
- Source maps are a hidden cost: each stage decodes the previous map and composes; large maps are megabytes of VLQ to parse.