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
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 (areturnoutside 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 2× 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.
- 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.
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?
Why does wrapping a startup function in parentheses — (function(){...})() — make V8 parse it eagerly?
Order the parse events for a normal (non-IIFE) function that is defined on load and then called once.
- 1 On load, V8 pre-parses the body: records its source span and checks early errors
- 2 No AST or scope is built yet; the engine moves on to the rest of the file
- 3 The function is called for the first time
- 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.
- 01What are V8's two parsers, and what does each do?
- 02Explain the double-parse problem and when lazy parsing is a net loss.
- 03How do you force a startup function to be parsed eagerly, and who uses this in practice?
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.