How V8 Parses JavaScript: Preparse, Lazy Compilation and the Parse Cache

V8 parses a script twice: a fast preparse that only finds function boundaries, then a full parse when the function is actually called. The preparse leaves behind a byte blob that lets V8 skip nested functions entirely.

TL;DR: V8 does not parse a function when it sees it — it preparses it, which means “run the scanner over the whole body, throw away every AST node, and write down where the nested functions are”. The result is a small byte blob (varints and two-bit “quarters”) attached to the SharedFunctionInfo as PreparseData. When the function is finally called, the full parser runs, and the blob lets it skip the nested functions outright instead of re-parsing them.

Parsing JavaScript is not the hard part of running JavaScript. Executing it is. But the cost of parsing is paid eagerly, at script load, while the benefit is only realised if a given function is ever called. A 2 MB bundle with 4000 functions, of which the running code path touches 40, is 99 % dead weight at parse time.

V8’s answer is a two-phase parser, and — this is the part that surprises people — the two phases are not “parse” and “compile”. They are preparse and parse, and the gap between them is bridged by a byte blob.

Two parsers, one grammar

The tree has a Parser and a PreParser, and they share almost all of their code through the CRTP base ParserBase<Impl> (src/parsing/parser-base.h). The PreParser is not a separate implementation of JavaScript; it is the same recursive-descent grammar with every AST-producing call replaced by a no-op. Look at how the preparser handles a template literal, for instance:

cpp
// src/parsing/preparser.h:986
  struct TemplateLiteralState {};

  V8_INLINE TemplateLiteralState OpenTemplateLiteral(int pos) {
    return TemplateLiteralState();
  }
  V8_INLINE void AddTemplateExpression(TemplateLiteralState* state,
                                       const PreParserExpression& expression) {}
  V8_INLINE void AddTemplateSpan(TemplateLiteralState* state, bool should_cook,
                                 bool tail) {}

Nothing is built. The point of the preparser is not to build an AST, it is to find the ends of things and to validate the grammar on the way.

Both live in the same Parser object, and the parser owns a separate zone for the preparser’s temporary allocations:

cpp
// src/parsing/parser.h:1106
  LocalIsolate* local_isolate_;
  ParseInfo* info_;
  Scanner scanner_;
  Zone preparser_zone_;
  PreParser* reusable_preparser_;

The PreParser is reused across functions, so its zone and its std::vector scratch buffers are allocated once per parse, not once per function:

cpp
// src/parsing/parser.h:254
  PreParser* reusable_preparser() {
    if (reusable_preparser_ == nullptr) {
      reusable_preparser_ = new PreParser(
          &preparser_zone_, &scanner_, stack_limit_, ast_value_factory(),
          pending_error_handler(), runtime_call_stats_, v8_file_logger_,
          flags(), parsing_on_main_thread_);
      reusable_preparser_->set_allow_eval_cache(allow_eval_cache());
      if (has_module_in_scope_chain()) {
        reusable_preparser_->set_has_module_in_scope_chain();
      }
      preparse_data_buffer_.reserve(128);
    }
    return reusable_preparser_;
  }

That reserve(128) is the whole preparse-data buffer, pre-sized for the typical case of a function with a handful of nested functions.

The scanner is a separate machine

Before you can preparse, you have to tokenise, and V8’s tokeniser is a distinct object (Scanner) that the parser drives. It is not a lexer inside the parser; it is a small state machine with its own buffer of characters and its own buffer of tokens.

The token buffer is a four-slot ring, which is why the parser can look three tokens ahead without re-scanning:

cpp
// src/parsing/scanner.h:795
  const TokenDesc& next_next_next() const { return *next_next_next_; }

  TokenDesc* current_;    // desc for current token (as returned by Next())
  TokenDesc* next_;       // desc for next token (one token look-ahead)
  TokenDesc* next_next_;  // desc for the token after next (after peek())
  TokenDesc* next_next_next_;  // desc for the token after next of next (after

Scanner::Next() rotates those four slots, and only scans a new token when the ring has run dry:

cpp
// src/parsing/scanner.cc:163
Token::Value Scanner::Next() {
  // Rotate through tokens.
  TokenDesc* previous = current_;
  current_ = next_;
  // Either we already have the next token lined up, in which case next_next_
  // simply becomes next_. In that case we use current_ as new next_next_ and
  // clear its token to indicate that it wasn't scanned yet. Otherwise we use
  // current_ as next_ and scan into it, leaving next_next_ uninitialized.
  if (V8_LIKELY(next_next().token == Token::kUninitialized)) {
    DCHECK(next_next_next().token == Token::kUninitialized);
    next_ = previous;
    // User 'previous' instead of 'next_' because for some reason the compiler
    // thinks 'next_' could be modified before the entry into Scan.
    previous->after_line_terminator = false;
    Scan(previous);
  } else {

Note the comment: the code is written to keep the common case (ring empty, scan one token) on the fast path, and the four-way rotation is what lets PeekAhead() and PeekAheadAhead() work without unbounded lookahead.

Strings and templates are different kinds of token

Token::Value distinguishes a plain string from the two template tokens, because a template is a sequence of tokens, not one:

cpp
// src/parsing/token.h:60
  /* BEGIN Template */
  /* ES6 Template Literals */
  T(kTemplateSpan, nullptr, 0)
  T(kTemplateTail, nullptr, 0)
  /* END Template */

A kTemplateSpan ends with ${ and is followed by an expression; a kTemplateTail ends the literal. The scanner keeps two buffers for a template: the cooked characters and the raw characters, because `\n` is a newline in the cooked form and a backslash-n in the raw form, and the raw form is what String.raw needs.

The string scanner is the hot loop of the whole tokeniser, and it is written to bulk-copy ASCII runs rather than character by character:

cpp
// src/parsing/scanner.cc:561
Token::Value Scanner::ScanString() {
  base::uc32 quote = c0_;

  next().literal_chars.Start();
  while (true) {
    // Consumed ASCII characters are bulk-appended to the literal buffer via
    // the range callback. Non-ASCII characters terminate the range scan and
    // are handled by the outer loop below (via the final AddLiteralChar),
    // which keeps the common all-ASCII scan fast.
    AdvanceUntilRange(
        [](base::uc32 c0) {
          if (V8_UNLIKELY(static_cast<uint32_t>(c0) > kMaxAscii)) return true;
          return MayTerminateString(character_scan_flags[c0]);
        },
        [this](const uint16_t* start, const uint16_t* end) {
          next().literal_chars.AddRangeFromUtf16(start, end);
        });

character_scan_flags is a 256-entry lookup table, indexed by ASCII character, that says whether the character can terminate a string. One table lookup per character instead of a chain of comparisons.

Surrogate pairs are handled one level down, in the character stream, by combining a lead surrogate with the following trail surrogate before the scanner ever sees it:

cpp
// src/parsing/scanner.h:610
  bool CombineSurrogatePair() {
    DCHECK(!unibrow::Utf16::IsLeadSurrogate(kEndOfInput));
    if (unibrow::Utf16::IsLeadSurrogate(c0_)) {
      base::uc32 c1 = source_->Advance();
      DCHECK(!unibrow::Utf16::IsTrailSurrogate(kEndOfInput));
      if (unibrow::Utf16::IsTrailSurrogate(c1)) {
        c0_ = unibrow::Utf16::CombineSurrogatePair(c0_, c1);
        return true;
      }
      source_->Back();
    }
    return false;
  }

This is why an identifier containing an astral character is a single uc32 in the literal buffer but two uint16_ts in the final string: the scanner works in code points, the string works in code units.

Preparse: the parser decides to skip the body

The decision to preparse rather than fully parse is made in Parser::ParseFunctionLiteral, and it is a small boolean with a lot of conditions behind it:

cpp
// src/parsing/parser.cc:2922
  // Determine whether we can lazy parse the inner function. Lazy compilation
  // has to be enabled, which is either forced by overall parse flags or via a
  // ScopedModification.
  const bool can_preparse = parse_lazily();

  // Determine whether we can post any parallel compile tasks. Preparsing must
  // be possible, there has to be a dispatcher, and the character stream must be
  // cloneable.
  const bool can_post_parallel_task =
      can_preparse && info()->dispatcher() &&
      scanner()->stream()->can_be_cloned_for_parallel_access();

  // If parallel compile tasks are enabled, and this isn't a re-parse, enable
  // parallel compile for the subset of functions as defined by flags.
  bool should_post_parallel_task =
      can_post_parallel_task && !flags().is_reparse() &&
      ((is_eager_top_level_function &&
        flags().post_parallel_compile_tasks_for_eager_toplevel()) ||
       (is_lazy && flags().post_parallel_compile_tasks_for_lazy()));

  // Determine whether we should lazy parse the inner function. This will be
  // when either the function is lazy by inspection, or when we force it to be
  // preparsed now so that we can then post a parallel full parse & compile task
  // for it.
  const bool should_preparse =
      can_preparse && (is_lazy || should_post_parallel_task);

Note the last clause: V8 will preparse a function it intends to compile eagerly, precisely so that the full parse can be shipped to a background thread. The preparse is not only a “skip” mechanism; it is also the half-price stage that makes parallel compilation possible.

If the preparse fails, the parser rewinds and does a full parse:

cpp
// src/parsing/parser.cc:2990
  bool did_preparse_successfully =
      should_preparse &&
      SkipFunction(function_literal_id, function_name, kind,
                   function_syntax_kind, scope, &num_parameters,
                   &function_length, &produced_preparse_data);

  if (!did_preparse_successfully) {
    // If skipping aborted, it rewound the scanner until before the lparen.
    // Consume it in that case.
    if (should_preparse) Consume(Token::kLeftParen);
    should_post_parallel_task = false;
    ParseFunction(&body, function_name, pos, kind, function_syntax_kind, scope,
                  &num_parameters, &function_length, &has_duplicate_parameters,
                  &expected_property_count, &suspend_count,
                  arguments_for_wrapped_function);

The one heuristic: “is this function about to be called?”

V8 does not use a size threshold to decide what to preparse. It uses exactly one syntactic signal: was this function preceded by an open parenthesis? If so, it is probably an IIFE, and it is about to run, so there is no point in being clever.

The signal is captured by FunctionState::set_next_function_is_likely_called:

cpp
// src/parsing/parser-base.h:483
    void set_next_function_is_likely_called() {
      next_function_is_likely_called_ = !v8_flags.max_lazy;
    }

and it is set the moment the parser sees a ( followed by function:

cpp
// src/parsing/parser-base.h:2245
      // Heuristically try to detect immediately called functions before
      // seeing the call parentheses.
      if (peek() == Token::kFunction ||
          (peek() == Token::kAsync && PeekAhead() == Token::kFunction)) {
        function_state_->set_next_function_is_likely_called();
      }

The flag then decides two things. First, whether the function is compiled eagerly:

cpp
// src/parsing/parser.cc:2863
  FunctionLiteral::EagerCompileHint eager_compile_hint =
      function_state_->next_function_is_likely_called() || is_wrapped ||
              parsing_dynamic_function_declaration_ ||
              (info()->flags().compile_hints_magic_enabled() &&
               scanner()->SawMagicCommentCompileHintsAll()) ||
              (info()->flags().compile_hints_per_function_magic_enabled() &&
               scanner()->HasPerFunctionCompileHint(compile_hint_position))
          ? FunctionLiteral::kShouldEagerCompile
          : default_eager_compile_hint();

Second — and this is the interesting one — whether its nested functions can be skipped wholesale later. In the preparser:

cpp
// src/parsing/preparser.cc:275
  int function_literal_id = GetNextInfoId();
  bool skippable_function = false;

  // Start collecting data for a new function which might contain skippable
  // functions.
  {
    PreparseDataBuilder::DataGatheringScope preparse_data_builder_scope(this);
    skippable_function = !function_state_->next_function_is_likely_called() &&
                         preparse_data_builder_ != nullptr;

Read that twice. A function is skippable — meaning “the next time we parse this, we may jump over its body entirely” — only if it is not likely to be called immediately. If it is likely to be called, V8 preparses it in full detail, because it is going to be needed anyway and there is no point in throwing the information away.

That is a genuinely odd design if you think of preparse as “the cheap approximation”. It is not an approximation. It is a cache: the preparser collects exactly the information that a later full parse would have collected, in a compressed form, so the later parse can skip work it has already done.

Preparse data: the blob you can skip a function with

The file src/parsing/preparse-data.h opens with the design note, and it is worth reading in full because it is the clearest statement of intent in the whole parser:

cpp
// src/parsing/preparse-data.h:45
  When parsing the code the first time, eager_outer is parsed and lazy_inner
  (and everything inside it) is preparsed. When lazy_inner is called, we don't
  want to parse or preparse skip_me again. Instead, we want to skip over it,
  since it has already been preparsed once.

  In order to be able to do this, we need to store the information needed for
  allocating the variables in lazy_inner when we preparse it, and then later do
  scope allocation based on that data.

  We need the following data for each scope in lazy_inner's scope tree:
  For each Variable:
  - is_used
  - maybe_assigned
  - has_forced_context_allocation

  For each Scope:
  - inner_scope_calls_eval_.

Three bits per variable, one bit per scope. That is the entire payload, plus a per-function header with the positions and the arity.

The header is five varints and one quarter-byte:

cpp
// src/parsing/preparse-data.cc:286
bool PreparseDataBuilder::SaveDataForSkippableFunction(
    PreparseDataBuilder* builder) {
  DeclarationScope* function_scope = builder->function_scope_;
  // Start position is used for a sanity check when consuming the data, we could
  // remove it in the future if we're very pressed for space but it's been good
  // at catching bugs in the wild so far.
  byte_data_.WriteVarint32(function_scope->start_position());
  byte_data_.WriteVarint32(function_scope->end_position());

  bool has_data = builder->HasData();
  bool length_equals_parameters =
      function_scope->num_parameters() == builder->function_length_;
  uint32_t has_data_and_num_parameters =
      HasDataField::encode(has_data) |
      LengthEqualsParametersField::encode(length_equals_parameters) |
      NumberOfParametersField::encode(function_scope->num_parameters());
  byte_data_.WriteVarint32(has_data_and_num_parameters);
  if (!length_equals_parameters) {
    byte_data_.WriteVarint32(builder->function_length_);
  }
  byte_data_.WriteVarint32(builder->num_inner_functions_);

  uint8_t language_and_super =
      LanguageField::encode(function_scope->language_mode()) |
      UsesSuperField::encode(function_scope->uses_super_property());
  byte_data_.WriteQuarter(language_and_super);
  return has_data;
}

The varint encoding is the same one the value serializer uses, and the “quarter” is a two-bit field packed four-per-byte:

cpp
// src/parsing/preparse-data.cc:212
void PreparseDataBuilder::ByteData::WriteQuarter(uint8_t data) {
  DCHECK(!is_finalized_);
  DCHECK_LE(data, 3);
  if (free_quarters_in_last_byte_ == 0) {
#ifdef DEBUG
    // Save a marker in debug mode.
    Add(kQuarterMarker);
#endif
    Add(0);
    free_quarters_in_last_byte_ = 3;
  } else {
    --free_quarters_in_last_byte_;
  }

  uint8_t shift_amount = free_quarters_in_last_byte_ * 2;
  DCHECK_EQ(byte_data_->at(index_ - 1) & (3 << shift_amount), 0);
  (*byte_data_)[index_ - 1] |= (data << shift_amount);
}

The language mode and the “uses super” flag are two independent bits, and they are squeezed into the same two-bit quarter as the encoding of the language mode. That is a level of bit-packing that only makes sense if you know how tight the budget is — and the budget is tight, because the whole point is to keep the blob small enough that it is cheaper to store it than to re-parse the function.

The size budget is spelled out in the constants:

cpp
// src/parsing/preparse-data.h:89
  static const size_t kSkippableFunctionMinDataSize =
      4 * kVarint32MinSize + 1 * kUint8Size;
  // A skippable function writes up to 5 Varint32s (start_position,
  // end_position, has_data_and_num_parameters, function_length when
  // length != num_parameters, and num_inner_functions) plus 1 Quarter
  // (language_and_super).
  static const size_t kSkippableFunctionMaxDataSize =
      5 * kVarint32MaxSize + 1 * kUint8Size;

In a release build a varint is 1 to 5 bytes, so a skippable function costs between 5 and 26 bytes of header. That is the price of being able to skip an entire function body.

From blob to SharedFunctionInfo

The blob is not stored on the SharedFunctionInfo directly. It is stored in an UncompiledData object, because the SharedFunctionInfo also needs to remember the start and end position of the function it describes, and because a function may additionally have a pending background compile job:

cpp
// src/objects/shared-function-info.cc:621
template <typename IsolateT>
void SharedFunctionInfo::CreateAndSetUncompiledData(IsolateT* isolate,
                                                    FunctionLiteral* lit) {
  DCHECK(!lit->shared_function_info()->HasUncompiledData(isolate));
  DirectHandle<UncompiledData> data;
  ProducedPreparseData* scope_data = lit->produced_preparse_data();
  if (scope_data != nullptr) {
    Handle<PreparseData> preparse_data = scope_data->Serialize(isolate);

    if (lit->should_parallel_compile()) {
      data = isolate->factory()->NewUncompiledDataWithPreparseDataAndJob(
          lit->GetInferredName(isolate), lit->start_position(),
          lit->end_position(), preparse_data);
    } else {
      data = isolate->factory()->NewUncompiledDataWithPreparseData(
          lit->GetInferredName(isolate), lit->start_position(),
          lit->end_position(), preparse_data);
    }
  } else {

Four variants: with or without preparse data, with or without a compile job. The “without preparse data” case is the one where the function had no skippable nested functions, and there is nothing to save.

Lazy compilation: the trampoline and the runtime

Nothing compiles a lazy function until it is called. The call goes through a trampoline that spots the “not compiled yet” state and calls into the runtime:

cpp
// src/runtime/runtime-compiler.cc:228
RUNTIME_FUNCTION(Runtime_CompileLazy) {
  HandleScope scope(isolate);
  DCHECK_EQ(1, args.length());
  DirectHandle<JSFunction> function = args.at<JSFunction>(0);
  StackLimitCheck check(isolate);
  if (V8_UNLIKELY(
          check.JsHasOverflowed(kStackSpaceRequiredForCompilation * KB))) {
    return isolate->StackOverflow();
  }

The stack check is not decoration: the full parse and bytecode generation need stack space, and V8 refuses to compile rather than blow the stack.

Compiler::Compile then re-parses the function — and this is where the preparse blob is handed back to the parser:

cpp
// src/codegen/compiler.cc:3085
    parse_info.set_consumed_preparse_data(ConsumedPreparseData::For(
        isolate, handle(shared_info->uncompiled_data_with_preparse_data(isolate)
                            ->preparse_data(),
                        isolate)));
  }

  // Parse and update ParseInfo with the results.
  if (!parsing::ParseAny(&parse_info, shared_info, isolate,
                         parsing::ReportStatisticsMode{true})) {

ConsumedPreparseData is the read side of the blob, and it is a strict sequential reader: the parser asks for “the data for the skippable function that starts at this position”, and the reader asserts that the position matches:

cpp
// src/parsing/preparse-data.cc:590
  // The skippable function *must* be the next function in the data. Use the
  // start position as a sanity check.
  typename ByteData::ReadingScope reading_scope(this);
  CHECK(scope_data_->HasRemainingBytes(
      PreparseByteDataConstants::kSkippableFunctionMinDataSize));
  int start_position_from_data = scope_data_->ReadVarint32();
  CHECK_EQ(start_position, start_position_from_data);

CHECK_EQ, not DCHECK_EQ. This code runs in release builds, because a mismatch here means the preparser and the parser have diverged and the whole skipping mechanism is unsound. It is a cheap tripwire that has “been good at catching bugs in the wild”, as the comment in the writer side says.

The skip

With the data in hand, the parser can jump over a function body entirely. The scanner is seeked forward to the recorded end position and the closing brace is simply consumed:

cpp
// src/parsing/parser.cc:3098
    function_scope->outer_scope()->SetMustUsePreparseData();
    function_scope->set_is_skipped_function(true);
    function_scope->set_end_position(end_position);
    scanner()->SeekForward(end_position - 1);
    Expect(Token::kRightBrace);
    SetLanguageMode(function_scope, language_mode);
    if (uses_super_property) {
      function_scope->RecordSuperPropertyUsage();
    }
    SkipInfos(num_inner_infos);
    function_scope->ResetAfterPreparsing(ast_value_factory_, false);
    return true;

SeekForward(end_position - 1) is the whole trick: the scanner does not tokenise the skipped body, it just moves its position. The function still gets a SharedFunctionInfo, a scope, correct arity, correct language mode — it just has no body yet. When it is called, it gets parsed, and its own children get skipped using its blob.

That is the recursion: the blob is a tree, one node per function, and parsing a function consumes one node and hands the rest to its children.

The compile cache

The blob above is the preparse cache: it lives on the SharedFunctionInfo and describes one function. The other cache is bigger and dumber: it maps source text to a compiled top-level SharedFunctionInfo, so that evaluating the same string twice does not parse it twice.

V8 keeps separate sub-caches for scripts, evals and regular expressions, because the same source text means different things in each:

cpp
// src/codegen/compilation-cache.h:18
// The compilation cache consists of several sub-caches: one each for evals and
// scripts, which use this class as a base class, and a separate generational
// sub-cache for RegExps. Since the same source code string has different
// compiled code for scripts and evals, we use separate sub-caches for different
// compilation modes, to avoid retrieving the wrong result.

The script cache is a hash table keyed by the source string. The eval cache is keyed by a tuple — source, the enclosing function’s SharedFunctionInfo, the language mode and the position of the eval call — because eval in two different places can mean different things.

The eval cache has an unusual two-phase insert. The first time a source string is evaluated, only a hash of it goes into the table, mapping to a lifetime counter; the second time, the real entry replaces it:

cpp
// src/objects/compilation-cache-table.h:129
  // Eval code only gets cached after a second probe for the
  // code object. To do so, on first "put" only a hash identifying the
  // source is entered into the cache, mapping it to a lifetime count of
  // the hash. On each call to Age all such lifetimes get reduced, and
  // removed once they reach zero. If a second put is called while such
  // a hash is live in the cache, the hash gets replaced by an actual
  // cache entry. Age also removes stale live entries from the cache.

The rationale is that most eval calls happen once, in a loop, or in code that is generated and thrown away. Caching them eagerly would pin source strings in memory for nothing. The placeholder survives kHashGenerations = 10 GCs before it is dropped.

The script cache has a different ageing rule: it does not remove entries whose bytecode has been flushed, it just blanks the value:

cpp
// src/codegen/compilation-cache.cc:66
void CompilationCacheScript::Age() {
  DisallowGarbageCollection no_gc;
  if (IsUndefined(table_)) return;
  Tagged<CompilationCacheTable> table = Cast<CompilationCacheTable>(table_);

  for (InternalIndex entry : table->IterateEntries()) {
    Tagged<Object> key;
    if (!table->ToKey(entry, &key)) continue;
    DCHECK(IsWeakFixedArray(key));

    Tagged<Object> value = table->PrimaryValueAt(entry);
    if (!IsUndefined(value)) {
      Tagged<SharedFunctionInfo> info = Cast<SharedFunctionInfo>(value);
      // Clear entries after Bytecode was flushed from SFI.
      if (!info->HasBytecodeArray()) {

The key survives so that the Script object — which holds the source string — is not collected. That matters for how V8 flushes code and bytecode: a script whose bytecode was flushed can be recompiled from the same source without a second trip through the parser.

When the preparse is thrown away

The preparser is not a validator. It is a scanner with a grammar, and the grammar check it performs is deliberately partial — the source of PreParser::PreParseFunction says so:

cpp
// src/parsing/preparser.cc:238
// Preparsing checks a JavaScript program and emits preparse-data that helps
// a later parsing to be faster.
// See preparser-data.h for the data.

// The PreParser checks that the syntax follows the grammar for JavaScript,
// and collects some information about the program along the way.
// The grammar check is only performed in order to understand the program
// sufficiently to deduce some information about it, that can be used
// to speed up later parsing. Finding errors is not the goal of pre-parsing,
// rather it is to speed up properly written and correct programs.
// That means that contextual checks (like a label being declared where
// it is used) are generally omitted.

But sometimes the preparser does hit an error, and sometimes it cannot work out where the error is. When that happens the preparse is thrown away entirely and the function is re-parsed by the real parser, purely so that the error message can point at the right character:

cpp
// src/parsing/parser.cc:3134
  } else if (pending_error_handler()->has_error_unidentifiable_by_preparser()) {
    // Make sure we don't re-preparse inner functions of the aborted function.
    // The error might be in an inner function.
    allow_lazy_ = false;
    mode_ = PARSE_EAGERLY;
    DCHECK(!pending_error_handler()->stack_overflow());
    // If we encounter an error that the preparser can not identify we reset to
    // the state before preparsing. The caller may then fully parse the function
    // to identify the actual error.
    bookmark.Apply();
    if (!private_name_scope_iter.Done()) {
      private_name_scope_iter.GetScope()->ResetUnresolvedPrivateNameTail(
          unresolved_private_tail);
    }
    function_scope->ResetAfterPreparsing(ast_value_factory_, true);
    pending_error_handler()->clear_unidentifiable_error();
    return false;

bookmark.Apply() rewinds the scanner to the position it had before the preparse started. allow_lazy_ = false and mode_ = PARSE_EAGERLY make sure the whole enclosing function, and not just the offending inner one, is parsed for real — because the error might be in a nested function and the preparser cannot pinpoint it.

The error bookkeeping itself is deliberately “first error wins”, by position:

cpp
// src/parsing/pending-compilation-error-handler.cc:81
void PendingCompilationErrorHandler::ReportMessageAt(int start_position,
                                                     int end_position,
                                                     MessageTemplate message,
                                                     const char* arg) {
  DCHECK((start_position == -1 && end_position == -1) ||
         (start_position != -1 && start_position <= end_position));
  if (has_pending_error_ && end_position >= error_details_.start_pos()) return;

  has_pending_error_ = true;

  error_details_ = MessageDetails(start_position, end_position, message, arg);
}

A later error that starts before the recorded one replaces it. That is the right rule for a parser: the error nearest the beginning of the source is the one the user most likely meant.

Trade-offs

What it buys. Parse time is proportional to the code that actually runs, not to the code that is loaded. A 4 MB bundle whose hot path is 3 % of it pays for 3 % of it at startup. The preparse data blob is what makes the second phase cheap: without it, compiling a lazy function would mean re-scanning everything inside it, and the preparse would have saved nothing.

What it costs.

  • Two passes over the source. The preparse scans every function body once, and the full parse scans the hot ones again. For a function that is called, the source is tokenised roughly twice. V8 buys this back with the (-heuristic, which keeps IIFEs — the functions most likely to be called — out of the double-scan path entirely.

  • Memory. Every lazily parsed function carries an UncompiledData with a position range and, usually, a preparse blob. On a large bundle that is tens of thousands of small heap objects.

  • The blob is a correctness liability. It is a hand-rolled serial format with a sequential reader and CHECK assertions in release builds. A divergence between preparser and parser is a crash, not a wrong answer, and the ScopeNeedsData predicate is the place where the two can disagree about the shape of a scope tree.

  • Debugging gets harder. The debugger asks for a re-parse, and it has to cope with the possibility that the re-parse fails because the preparser and the parser disagreed:

    cpp
    // src/debug/debug-scopes.cc:135
        // A failed reparse indicates that the preparser has diverged from the
        // parser, that the preparse data given to the initial parse was faulty, or
        // a stack overflow.
        // Silently fail by presenting an empty context chain.
    
  • The (-heuristic is a heuristic. (function(){ ... }) as an expression statement is compiled eagerly even if it is never called. The cost of getting it wrong is bounded — one function’s worth of eager compilation — but it is a real cost on codebases that use IIFEs as module wrappers, which is most codebases.

The bottom line

V8’s parser is a two-stage machine with an explicit cache between the stages. Stage one is the preparser: same grammar, no AST, and it produces a compact byte blob describing (a) where every nested function starts and ends, and (b) the three bits per variable that the later scope allocation needs. Stage two is the real parser, which runs only for functions that are actually called, and which uses the blob to skip the nested functions rather than re-scan them.

The mental model to keep: preparse data is not a summary of the AST. It is a recorded transcript of the work the parser would otherwise have to redo, encoded in varints and two-bit quarters, and it is the reason a lazy function compiles faster than a cold one.

The thing people get wrong

“JavaScript is parsed lazily” is usually taken to mean “the source is not read until it is needed”. It is not. Every function body in the file is tokenised at load time, whether or not it is ever called. What is lazy is the AST and the bytecode.

So --no-lazy does not save you the tokenisation pass, and a bundle cannot avoid being scanned by making its functions lazy. It only avoids building the trees. And the preparse blob is the reason that distinction matters: the preparser is not a cheaper approximation of the parser, it is the parser with the expensive part removed and the reusable part written down.

V8InternalsParserLazy