Viscosity
Review
Part V · Inside real JavaScript engines · Day 16

62. Parsing JavaScript for real

A page can ship a megabyte of JavaScript and call a fraction of it. So V8 reads most functions without really parsing them, and on the next visit tries not to compile them at all.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain lazy parsing, and what a preparser must still do with a body it skips.
  • Watch V8 preparse, parse and compile real functions with --log-function-events.
  • Explain why a lazily parsed function that does get called is parsed twice, and how V8 guesses which functions to parse eagerly.
  • Describe a bytecode cache, and use one in Node with vm.Script.

Most of the code is never called

Drip’s compiler from chapter 48 works on a whole program in one go: every function is parsed and turned into bytecode before anything runs. For a test script that’s fine.

A web page is different. It might ship a framework, a date library and the code for a settings dialog nobody opens today, and it has to start fast. In 2019 the V8 team listed the costs of compiling such code eagerly: CPU time that delays the code that is needed, memory for the bytecode, and disk space in the cache. Parsing and compiling sit on the critical path of page startup[1]. So the cheapest parse is the one you never do.

Lazy parsing

Intuition

When you’re handed a thick report, you don’t read every appendix. You check they’re there and roughly what they’re about, then read one properly only if someone asks about it. Skimming isn’t free, though: you still have to turn every page to find where each appendix ends.

All major browsers parse JavaScript lazily[1]. When V8’s parser reaches a function body, it can hand it to the preparser, a stripped-down copy of the parser that skims the body without building a syntax tree. When the function is called for the first time, V8 parses it properly and compiles it then. Step through a small file:

Step throughLazy parsing, function by function
function used() { return 1; }
function unused() { return 2; }
const eager = (function () {
return 3;
});
used();
Three rows, one per function (used, unused, eager), each showing its current state: not reached, preparsed, fully parsed, or compiled to bytecode.FUNCTIONSTATEstartusednot reached yetunusednot reached yeteagernot reached yet
The top level of the file is about to run, so it must be parsed and compiled. The question is what to do with each function body on the way.
1/8

This is no simulation. V8 can log these events. Here is the same file:

// lazy.js
function used() { return 1; }
function unused() { return 2; }
const eager = (function () {
  return 3;
});
used();
// node --log-function-events
//   --logfile=fe.log lazy.js
// Node v22.23.4, V8 12.4.254.21
// event, start, end, name (lines for
// the whole file left out)
preparse-resolution  13  29 used
preparse-resolution  45  61 unused
full-parse           86 104
interpreter          86 104 eager
full-parse           13  29 used
parse-function       13  29 used
interpreter          13  29 used
first-execution      13  29 used

The numbers are character positions in the file. used and unused were preparsed. The parenthesized function was fully parsed and compiled straight away. When used() ran, it got its full parse and its bytecode (the interpreter event). unused never got past the preparser.

Definition

Lazy parsing means skimming a function body with a preparser instead of parsing it fully, and doing the full parse and compile only when the function is first called. The preparser still checks the body’s syntax and records what the enclosing code needs to know about it[1].

Skimming is harder than it looks

Why not just jump to the matching }? Because finding it means understanding the syntax. A brace can sit inside a string, a comment, a regular expression or a template literal. In 2025 the V8 team put it bluntly: there is no shortcut of counting curly braces; the grammar is too complex[2].

Try itCounting braces goes wrong
const src = '{ return "}"; } more';
let depth = 0, end = -1;
for (let i = 0; i < src.length; i++) {
  if (src[i] === '{') depth++;
  if (src[i] === '}') depth--;
  if (depth === 0) { end = i; break; }
}
console.log(end, src.slice(0, end + 1));
// It stopped at the } in the string.

The preparser also has to keep some information. Remember closures from chapter 57: a variable that an inner function captures must outlive its function call, so V8 stores it in a heap-allocated context instead of on the stack. Whether a variable needs that depends on the functions nested inside, so the preparser tracks the variables each skipped body declares and refers to[1].

And because the language demands it, a syntax error inside a function nobody calls still stops the whole script:

Try itA never-called function still has to parse
const src = `
function never() {
  return 1 +;
}
return 'ran';`;
try {
  const f = new Function(src);
  console.log(f());
} catch (e) {
  console.log(e.name);
}
// never() is never called, but the
// whole thing still fails to load.

Guessing what will be called

Lazy parsing has a cost when it guesses wrong. A function that is preparsed and then called gets parsed twice: once to skim, once for real. Since V8 6.3 (Chrome 63, 2017) it’s at most twice, however deeply the function is nested[1].

So V8 guesses. A function written in parentheses, (function () {…}), is usually an immediately-invoked function, so V8 parses and compiles it eagerly as soon as it sees (function. The team calls these PIFEs, possibly-invoked function expressions[1]. That’s why eager was compiled before anything ran.

Developers can give hints too. In 2025 the team wrote that Chrome 136 compiles every function in a file eagerly if the file starts with the magic comment //# allFunctionsCalledOnLoad, and warned to use it sparingly, because compiling too much costs time and memory[2].

Don’t compile it again: bytecode caches

Intuition

A café regular orders the same thing every morning. The barista could start from the menu each time, or just keep the order written down. As long as the regular doesn’t change their mind, the note saves the work.

Lazy parsing saves work on the first visit. A bytecode cache saves it on the next one. V8 can serialize compiled code into bytes, and a later compile of the same script can rebuild it from those bytes instead of parsing and compiling[3]. Chrome uses this in three steps[5]:

Three visits to the same page. Cold run: download, parse and compile, store the file. Warm run: the file comes from the cache but is still compiled, and this time the compiled bytecode is saved next to it. Hot run: file and bytecode come from the cache, and compiling is skipped.cold rundownload scriptparse + compilestore the filevisit 1warm runfile from cacheparse + compilesave bytecode toovisit 2hot runfile + bytecodedeserializeskip compilingvisit 3
A cached script skips parsing and compiling. Chrome’s code cache, as the V8 team described it in 2019 (updated 2020). The first visit stores the file, the second also stores its compiled code, and from the third the compile step is skipped.

Since Chrome 66 (2018) the cache is made after the script’s top level has run, so it also holds lazy functions that ran during startup[4]. Node exposes the same machinery through vm.Script[6]:

const vm = require('node:vm');
const src = `
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }
add(1, 2);`;
const s = new vm.Script(src);
const before = s.createCachedData();
s.runInThisContext();
const after = s.createCachedData();
console.log(before.length, after.length);

const s2 = new vm.Script(src,
  { cachedData: after });
console.log(s2.cachedDataRejected);
const s3 = new vm.Script(src + ' ',
  { cachedData: after });
console.log(s3.cachedDataRejected);
Node v22.23.4, V8 12.4.254.21
528 672
false
true

After running, the cache grew: add had been compiled by then, so its bytecode came along, while sub stayed lazy. The cache was accepted for the identical source, and rejected when one space was added. A cache is only good for exactly the code it came from.

Definition

A bytecode cache (or code cache) stores a script’s compiled bytecode so a later load of the same script can skip parsing and compiling. It is invalidated by any change to the source, and by a different engine version[4].

Under the hood

Node 22 can do this for your modules automatically: module.enableCompileCache() or the NODE_COMPILE_CACHE environment variable keeps V8 code caches on disk, and a cache from one Node version isn’t reused by another[7]. All of this lives below Sparkplug, Maglev and TurboFan. Sparkplug compiles from bytecode, and relies on the bytecode compiler having already done the hard work, such as resolving variables[8]. The V8 team’s own advice on caching starts with “do nothing”: the heuristics change between releases[5].

Your turn

ExerciseFind the end of a function body

Write findBodyEnd(src, i). src[i] is a {. Return the index of the } that closes it. Braces inside string literals ("…" or '…', where a backslash escapes the next character) and inside // line comments don’t count. Return -1 if there is no match. (A real preparser also handles regular expressions, template literals and more; you’ll see why it isn’t simple.)

function findBodyEnd(src, i) {
  let depth = 0;
  for (; i < src.length; i++) {
    if (src[i] === '{') depth++;
    if (src[i] === '}') depth--;
    if (depth === 0) return i;
  }
  return -1;
}
Extra practiceSpot a possibly-invoked function

V8 parses a function eagerly if it looks immediately invoked. Write looksInvoked(src, i), where src starts the keyword function at index i. Return true if the nearest character before i that isn’t a space or newline is ( or !, and false otherwise (including when there is none).

function looksInvoked(src, i) {
  const c = src[i - 1];
  return c === '(' || c === '!';
}

What you learned

  • Lazy parsing skims function bodies with a preparser and parses and compiles them fully only when they are first called.
  • The preparser can’t just count braces, and must still check syntax and track the variables that closures capture.
  • A called lazy function is parsed twice, so V8 parses likely-called functions (like (function) eagerly.
  • A bytecode cache stores compiled code so the same script can skip compiling next time; any change to the source invalidates it.
  • In Node, vm.Script and the module compile cache expose the same machinery.

Next: what V8 does with an object once it exists. Shapes.

Check your understanding

0/6
  1. 1.

    What does V8’s preparser produce for a function body it skims?

  2. 2.

    A preparsed function is called once. How many times has V8 parsed it?

  3. 3.

    Why does V8 compile (function () { … }) eagerly?

  4. 4.

    Why must the preparser keep track of variables in a body it skips?

  5. 5.

    In the vm.Script experiment, why was the second cache bigger than the first?

  6. 6.

    Which change makes a stored bytecode cache useless?

References

  1. [1] Toon Verwaest, Marja Hölttä (2019). Blazingly fast parsing, part 2: lazy parsing. V8 blog (v8.dev), 15 April 2019. v8.dev/blog/preparser (checked 2026-10-31)
  2. [2] Marja Hölttä (2025). Giving V8 a Heads-Up: Faster JavaScript Startup with Explicit Compile Hints. V8 blog (v8.dev), 29 April 2025. v8.dev/blog/explicit-compile-hints (checked 2026-10-31)
  3. [3] Yang Guo (2015). Code caching. V8 blog (v8.dev), 27 July 2015. v8.dev/blog/code-caching (checked 2026-10-31)
  4. [4] Mythri Alle (2018). Improved code caching. V8 blog (v8.dev), 24 April 2018. v8.dev/blog/improved-code-caching (checked 2026-10-31)
  5. [5] Leszek Swirski (2019). Code caching for JavaScript developers. V8 blog (v8.dev), 8 April 2019, updated 16 June 2020. v8.dev/blog/code-caching-for-devs (checked 2026-10-31)
  6. [6] Node.js contributors (2024). Node.js v22 documentation: VM (executing JavaScript): cachedData, script.cachedDataRejected and script.createCachedData(). Node.js documentation (nodejs.org), v22.x. nodejs.org/docs/latest-v22.x/api/vm.html#scriptcreatecacheddata (checked 2026-10-31)
  7. [7] Node.js contributors (2024). Node.js v22 documentation: Modules, module compile cache and module.enableCompileCache(). Node.js documentation (nodejs.org), v22.x. nodejs.org/docs/latest-v22.x/api/module.html#module-compile-cache (checked 2026-10-31)
  8. [8] Leszek Swirski (2021). Sparkplug — a non-optimizing JavaScript compiler. V8 blog (v8.dev), 27 May 2021. v8.dev/blog/sparkplug (checked 2026-10-31)