open atlas
↑ Back to track
JavaScript Engine internals JSE · 01 · 02

Lazy parsing and the pre-parser

V8 runs two parsers: the full parser that builds an AST and allocates scopes, and the PreParser that skims function bodies to find boundaries and early errors without building a tree. Functions are pre-parsed on first load and fully parsed only when first called

JSE Junior ◷ 13 min
Level
FoundationsJuniorMiddleSenior

Your app bundle defines four hundred functions on load, but the cold path calls maybe twenty of them. If V8 fully parsed all four hundred up front — building an AST and a scope object for every one — most of that work would be thrown away unread. So it doesn’t. It does a fast skim of each function body it can defer, and only fully parses a function the first time someone actually calls it. The catch: a function that is called ends up parsed twice.

Two parsers, not one

The previous lesson described “the parser” as a single thing. In reality V8 ships two parsers that share a scanner. Knowing which one runs — and when — is what lets you reason about startup cost rather than just guessing:

  • The full parser. Builds a complete AST, resolves variable scopes, allocates scope objects — everything the bytecode generator needs. This is the expensive one.
  • The PreParser. A deliberately incomplete parser that skims a function body fast. It does not build an AST and does not allocate scopes. Its job is narrow: find where the function ends (so the engine knows the byte range to come back to), and detect early errors — syntax errors and a handful of SyntaxError-class problems (a return outside a function, a duplicate parameter in strict mode) that the spec requires reported even for code that never runs.

On a cold load, V8 does not fully parse most function bodies. It pre-parses them: skim, record the source span and a little metadata, move on. The full parse of a given function is deferred until the moment the function is first called. This is lazy parsing, and it is the single biggest startup win in the front-end pipeline, because most functions in a typical bundle are never called on the cold path.

The trap: a called function is parsed twice

Lazy parsing is a bet. The bet pays off for functions that are never called: you spent one cheap skim instead of one expensive full parse. But for a function that is called, you lose the bet — you pay the pre-parse skim and then the full parse. That is the double-parse problem: a lazily-parsed function that later runs costs roughly the parse work of a function that had been parsed eagerly once.

For most code this is the right trade: the savings on the never-called majority outweigh the double cost on the called minority. But on the hot startup path — the functions that you know run immediately, like your framework’s bootstrap or a module’s top-level initialiser — paying for two parses is pure waste. You would rather V8 just parse those eagerly, once.

Pre-parser vs full parser
PreParser builds an AST?
no
PreParser allocates scopes?
no
PreParser speed vs full parse
roughly ~2x faster
Called lazily-parsed function pays
pre-parse + full parse
Double-parse penalty
~2x parse work
Early errors caught at pre-parse
syntax, dup params (strict)

The eager-parse heuristic: parentheses mean “run me now”

V8 has a heuristic to opt a function out of lazy parsing: if a function expression is wrapped in parentheses, V8 parses it eagerly, because the parentheses are the classic signal of an Immediately Invoked Function Expression (IIFE) — a function the author intends to call right away.

// Eagerly parsed: the parens signal "this runs immediately".
(function () {
  bootstrapTheApp();
})();

// Lazily parsed by default: looks like a definition to call later.
function maybeLater() {
  doExpensiveThing();
}

A function written to look like an IIFE — even with the parens but invoked separately — is called a PIFE (Possibly-Invoked Function Expression), and V8 treats the leading ( as the eager-parse hint. This is exactly the trick the old optimize-js tool exploited: it mechanically wrapped module-bootstrap functions in parentheses so V8 would parse them eagerly and skip the double-parse on the startup path. Modern bundlers (webpack, Rollup) emit IIFE/PIFE wrappers around module factories for the same reason — it is a real, measurable startup optimisation, not folklore.

Inner functions ride along with their outer function

There is a structural subtlety: when V8 fully parses an outer function, it pre-parses the inner functions nested inside it — it does not fully parse them too. So a deeply nested helper is pre-parsed when its enclosing function is fully parsed, and only fully parsed when that inner helper is itself first called. The laziness is recursive: each level defers the next. This is why a giant module-scope function with many nested closures can still start cheaply — the nesting is skimmed, not fully built, until each piece runs.

Quiz

A function on your bootstrap path is lazily parsed and then called once during startup. How many times is its body parsed, and is that optimal here?

Quiz

Why does wrapping a startup function in parentheses — (function(){...})() — make V8 parse it eagerly?

Order the steps

Order the parse events for a normal (non-IIFE) function that is defined on load and then called once.

  1. 1 On load, V8 pre-parses the body: records its source span and checks early errors
  2. 2 No AST or scope is built yet; the engine moves on to the rest of the file
  3. 3 The function is called for the first time
  4. 4 V8 goes back and fully parses that span — building the AST and scopes — then emits bytecode
Edge cases

There is a subtle correctness reason the PreParser exists at all rather than V8 simply skipping un-called functions: the spec requires early errors to be reported for syntactically invalid code even if it never executes. So a function with a syntax error must throw at load time, not silently lurk until called. The PreParser is the cheapest way to honour that: it validates syntax and a few early-error rules (like duplicate parameters in strict mode) without paying for a full AST.

Recall before you leave
  1. 01
    What are V8's two parsers, and what does each do?
  2. 02
    Explain the double-parse problem and when lazy parsing is a net loss.
  3. 03
    How do you force a startup function to be parsed eagerly, and who uses this in practice?
Recap

V8 does not fully parse every function on load — it ships two parsers. The full parser builds the AST, resolves scopes, and allocates scope objects (the expensive path the bytecode generator needs), while the PreParser is a fast, deliberately incomplete skim that builds no AST and no scopes: it only finds each function’s source span and reports early errors (syntax errors, strict-mode duplicate parameters) that the spec demands even for code that never runs. On a cold load V8 pre-parses function bodies and fully parses a given function only the first time it is called — lazy parsing, the biggest startup win in the front end, because most functions are never called on the cold path. The trade-off is the double-parse problem: a function that is called pays both the pre-parse and the full parse, roughly 2× parse work, which is wasteful for code you know runs immediately. The escape hatch is the eager-parse heuristic: a function wrapped in parentheses, an IIFE/PIFE (function(){…})(), is parsed eagerly because the leading ( signals immediate execution — the trick the optimize-js tool and modern bundlers use to keep the startup path off the double-parse. Inner functions are pre-parsed when their outer function is fully parsed, so the laziness is recursive, level by level. Now when you see a slow cold start despite a small bundle, ask yourself: are my bootstrap functions wrapped for eager parsing, or are they paying the double-parse tax?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.