Parsing, the AST and Lazy Compilation

Paste this into a fresh tab and run it:

console.log("starting up");

function totals(rows) {
  let sum = 0;
  for (const r of rows) sum += r.   // <- typo, unfinished
}

Nothing logs. Not “starting up”, not an error from inside totals, nothing you can act on except a SyntaxError. Line one is perfectly fine and it still refuses to run. The mistake is five lines down, inside a function nobody ever called.

That is worth sitting with, because it contradicts how most people picture JavaScript running. The engine does not read your file line by line and execute as it goes. Before a single statement runs, it reads the whole file top to bottom and rebuilds it as a data structure it can actually work with. If any part of that text is malformed, the structure can’t be built, so the file is rejected and none of it runs.

That read-and-rebuild step is parsing, and it is the front of the pipeline the previous lesson sketched. From source text to running code walked the whole journey: parse, then bytecode, then interpret, then tier up the hot parts. This lesson zooms all the way in on the first box. It is the part that runs before your code exists as anything runnable, and it quietly decides how fast your app starts.

The front end: characters in, a tree out

Your source file arrives as one long run of characters. To the engine, function is not a keyword yet. It is the letters f, u, n, c, t, i, o, n sitting next to each other. Turning that stream into something structured happens in two moves: a scanner groups characters into tokens, and a parser assembles tokens into a tree.

1 charactersif (x > 0)scanner2 tokensifkeyword(punctxidentifier>operator0number)punctparser3 syntax treeIfStatementtestBinaryExpression >leftrightIdentifier xNumericLiteral 0
The front end in two moves: the scanner groups characters into tokens, the parser assembles tokens into a tree.

Neither move is optional and neither is the slow part people imagine. The scanner is close to a straight character loop. The parser is where the interesting decisions live, including the one that saves your startup time, which we get to below. First, the two moves in turn.

Scanning: from characters to tokens

A token is the smallest run of characters that carries one unit of meaning. function, total, >=, "hello", 42, { are each a single token. The scanner (also called the lexer or tokenizer) walks the character stream and keeps bundling characters until it has one:

  • letters and digits run together into an identifier or, if they spell a reserved word, a keyword (if, return, const);
  • digits become a number; a quote starts a string that runs to the matching quote;
  • symbols like +, =>, === become operator or punctuation tokens, longest match first, so => is one token and not = then >.

Whitespace and comments are mostly thrown away between tokens. Mostly. A line break sometimes matters, because JavaScript’s automatic semicolon insertion can end a statement at a newline, which is exactly why return on its own line followed by a value on the next line does not do what you hoped. The scanner is the layer that has to track that.

Tokens are a flat list. if ( x > 0 ) becomes six tokens in a row with no nesting and no notion that the ( and ) belong together. Structure is the parser’s job.

Parsing: from tokens to a tree

The parser reads the token list and, following the grammar of the language, builds an Abstract Syntax Tree: a tree of nodes where each node is a construct in your program and its children are the pieces that make it up. A function node holds a parameter list and a body. An if node holds a test, a consequent, and maybe an alternate. A binary expression node holds a left side, an operator, and a right side.

“Abstract” means the tree keeps the structure and drops the noise. The parentheses, the exact whitespace, the semicolons: they did their job telling the parser how things group, and then they are gone. Two files that differ only in spacing produce the same AST.

Take a small function and look at its whole tree:

function grade(score) {
  if (score >= 60) {
    return "pass";
  }
  return "fail";
}
paramsbodytestconsequentleftrightFunctionDeclarationgradeIdentifierscoreBlockStatementthe bodyIfStatementbranchReturnStatementfailBinaryExpressionoperator: >=ReturnStatementpassIdentifierscoreNumericLiteral60
The AST for grade(): a FunctionDeclaration at the root, statement nodes below it, expression and literal nodes at the leaves. Node names are illustrative and vary by tool.

Read it top down and it is just your function said back to you as structure. The root is a FunctionDeclaration named grade. It has a parameter (score) and a body. The body is a block holding two statements: an if, and a return "fail". The if has a test (score >= 60, a binary comparison of an identifier and a number) and a consequent (return "pass"). Every leaf is a name or a literal value.

The exact node names are a convention, not a law, and they differ between tools and engines. What is universal is the shape: nested nodes, structure at the top, values at the bottom. This tree is the thing every later stage reads. The bytecode generator from the previous lesson walks this tree to emit bytecode. It never looks at your original text again.

Pick a snippet below and expand its tree. Nothing here runs a real parser; the trees are authored by hand so the structure is easy to see. Click any node with children to fold it:

interactiveA mini AST viewer

Lazy parsing: the trick that makes startup bearable

Here is the problem the engine actually faces. A real app ships a lot of functions, and on any given page load most of them never run. Error handlers, settings screens, that whole feature behind a tab nobody clicked. Fully parsing every one of them up front, building a complete AST for code that will never execute, is wasted work paid at the worst possible moment: before your app is interactive.

So V8 doesn’t do it. When the parser reaches a function it isn’t about to run, it switches to a stripped-down pre-parser that does the bare minimum: scan to the matching closing brace so it knows where the function ends, confirm the syntax is valid, and note which outer variables the function refers to. It does not build the function’s AST. It skips the body. This is lazy parsing, and the full parse is deferred until the moment the function is first called.

app.js as shippedskimfunction setup() { configure(opts)}function render() { paint(scene)}function legacyExport() { // big, rarely run}called on loadfull parse builds the ASTcalled on loadfull parse builds the ASTnever calledstays skimmed, no AST built
Pre-parse skims every function once for its boundaries and syntax. The full parse that builds an AST happens only when a function is actually called.

The payoff is large because the skim is genuinely cheap compared to a full parse, and the code that never runs never gets the expensive treatment. Startup gets to skip most of the parsing bill.

There is a catch, and it is worth naming honestly. A function that does get called was pre-parsed once and now has to be fully parsed too. That is two passes over the same source, a double parse. For a function that runs, lazy parsing is slightly slower than if you had just parsed it eagerly the first time. The bet is statistical: across a whole app, so much code never runs that skipping it wins easily, even after paying double for the functions that do run. V8 has also worked hard to make the second pass cheaper, for instance by having the pre-parser record where variables live so the full parse doesn’t have to rediscover it.

Your bundle size is a parse bill

Now the part that touches code you actually ship. Everyone knows a bigger bundle takes longer to download. The cost people forget is that every byte the browser keeps also has to be scanned, and its structure at least skimmed, before your first line runs. Download is a one-time transfer that caches well. Parse happens on the user’s device, on the main thread, on every cold load, on whatever phone they happen to own.

lean bundle (ship less)downloadparseyour code runsheavy bundle (huge dependencies)downloadparse + compile all of ityour code runsextra startup delay = the parse work you addedtime from load
Two apps, same code path. The heavy bundle pushes the moment your code first runs later, because there is simply more text to scan and skim first.

This reframes a familiar rule. “Ship less JavaScript” is not only about download weight. It is about how much text the engine has to chew through before anything happens. A giant dependency you pulled in for one helper function is a giant parse, even if that one function is all you use, because the whole file still has to be scanned and its functions skimmed for boundaries. The bytes you never call are not free; they are just cheaper.

Which is exactly why the build tools lesson and modules and packages matter to the engine and not just to your file count. Code splitting, tree shaking, and lazy imports all reduce one number the parser cares about: how much source it must process up front. When you defer a route’s code with a dynamic import(), you are not only deferring a download. You are deferring its parse. This is the concrete, mechanical reason bundle discipline shows up in Core Web Vitals: parse-and-compile time is main-thread time, and main-thread time is what a user feels as a slow, unresponsive start.

Forcing an eager parse (and why you usually shouldn’t)

Lazy parsing has one clear failure case: a function that runs immediately. The classic shape is the IIFE, an expression you invoke the instant you define it:

(function () {
  // module setup that runs right now
})();

Skimming this function and then immediately fully parsing it is pure waste. You pay for the skim and get nothing, because the full parse happens a microsecond later anyway. Engines know this, so they look for the tell. When the parser sees a function expression wrapped in parentheses, or led by a ! or , the way minifiers write it, it treats it as probably about to run and parses it eagerly, skipping the pointless skim.

the same function body, two ways to parse itlazy path: function runs right awaypre-parse (skim)full parsethe skim was wasted workeager path: V8 recognizes the IIFEfull parse oncework the IIFE heuristic saves
For a function that runs immediately, lazy parsing pays for a skim it throws away. Spotting the IIFE lets the engine parse the body just once.

For a while this heuristic turned into folklore. A build tool called optimize-js would wrap functions in extra parentheses so V8 would eagerly compile them, and for a moment it measurably helped startup on some sites. That era is essentially over. The advice from the V8 team now is the opposite: do not hand-wrap functions to force eager parsing. If you force eager compilation on functions that do not actually run at load, you make startup worse, because you are back to paying full parse cost for code that lazy parsing would have skipped. The heuristic is theirs to tune, not yours to game.

Same idea in other engines

V8 is the reference here, but the front end is not a V8 invention. Every serious engine scans to tokens, parses to a tree, and defers work it can avoid.

What this means for your code

You do not write code to please the parser. There is no clever syntax that parses faster in a way you should chase, and the eager-parse tricks are stale. What you do control is upstream of all of it: how much source reaches the parser, and when.

  • Ship less, and split what you ship. The single biggest parse lever is total bytes of JavaScript the engine must process before your app is interactive. Code splitting and lazy import() do not just defer downloads; they defer parses. That is real, measurable startup time.
  • A dependency’s cost includes its parse. A heavy library you use one function from still gets scanned and skimmed in full. Weigh imports by what they add to the bundle, not by how much of them you call.
  • Trust lazy parsing; do not fight it. The engine already skips the bodies you do not run. Hand-forcing eager parsing usually backfires. If you ever need to nudge it, reach for a measured, explicit compile hint, not a folklore trick.
  • Syntax errors are free feedback. They surface at parse time, before anything runs, which is why linters and type checkers catch them without executing your program. Lean on that. It is the cheapest bug to find.
  • Readable structure is free. Because the AST throws away whitespace and comments, formatting your code well costs the engine nothing at all. Write for humans, per coding style; the parser flattens the difference.

Summary

  • Before any code runs, the engine turns source text into structure in two moves: a scanner groups characters into tokens, and a parser assembles tokens into an Abstract Syntax Tree, the nested node structure every later stage reads.
  • The AST keeps structure and drops noise. Whitespace, comments, and grouping parentheses do their job and disappear, so formatting has no runtime cost.
  • Syntax errors are parse errors. They reject the whole file before execution starts, unlike runtime errors that fire mid-run.
  • Lazy parsing is the startup win: functions that are not about to run get a cheap pre-parse that finds their boundaries and checks syntax but builds no tree, deferring the full parse until first call. The tradeoff is a possible double parse for functions that do run, and it pays off because so much shipped code never runs.
  • Parse cost makes bundle size a startup bill, not just a download. Splitting and deferring code reduces how much source the engine processes up front, which is where this connects to build tools and Core Web Vitals.
  • Engines detect immediately-invoked functions and parse them eagerly, but hand-forcing eager parsing is stale advice; the modern lever is a sparing, explicit compile hint. Names and heuristics are engine-specific and change.

What this means for your code: you rarely optimize the parser directly. You optimize how much code reaches it, and how late. Ship less, split the rest, and let lazy parsing skip the code your users never touch.