The JSON Parser: How V8 Parses JSON Fast

V8's JSON parser is a hand-written, table-driven scanner with a SIMD string scanner, a Smi-first number path, and a feedback-map trick that predicts the shape of the next object in an array.

TL;DR: JSON.parse does not use the JavaScript parser at all. V8 has a separate hand-written parser (src/json/json-parser.cc) that is driven by two 256-entry lookup tables, scans strings with Highway SIMD, parses numbers Smi-first, and — the cleverest bit — takes the map of the previous element in a JSON array and uses it to predict the shape of the next one, so it can allocate the object with the right fields already in place.

JSON is the most common data format in the world, and the most common operation on it is JSON.parse of a large payload. It is also the easiest format to parse, and V8 treats it that way: the JSON parser shares no code with the JavaScript parser. It is a purpose-built machine with its own token type, its own scanner and its own object builder.

The interesting parts are not the grammar. They are the parts where V8 cheats.

Two parsers, and only one of them is JavaScript

The public entry point is a C++ builtin, not a Torque one:

cpp
// src/builtins/builtins-json.cc:16
// ES6 section 24.3.1 JSON.parse.
BUILTIN(JsonParse) {
  HandleScope scope(isolate);
  Handle<Object> source = args.atOrUndefined(isolate, 1);
  Handle<Object> reviver = args.atOrUndefined(isolate, 2);
  Handle<String> string;
  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, string,
                                     Object::ToString(isolate, source));
  string = String::Flatten(isolate, string);
  RETURN_RESULT_OR_FAILURE(
      isolate,
      String::IsOneByteRepresentationUnderneath(*string)
          ? JsonParser<uint8_t>::Parse(isolate, string, reviver, std::nullopt)
          : JsonParser<uint16_t>::Parse(isolate, string, reviver,
                                        std::nullopt));
}

Two things happen here that matter.

First, the source is flattened. A JSON payload that arrives as a sliced or cons string is turned into a flat sequential string, because the parser wants a raw char* it can walk with pointer arithmetic.

Second, the parser is instantiated on the character width of the string. JsonParser<uint8_t> and JsonParser<uint16_t> are two separate compilations of the same template, and the one-byte version is faster because it can move 16 bytes at a time in the SIMD string scanner and because a one-byte string can be represented by a smaller heap object.

Note also that Object::ToString is called before the parser starts. That is the one place where JSON.parse can run user code: if you pass an object with a toString method, it gets called. After that, the parse itself is user-code-free.

The token type

JSON’s grammar is small enough that the whole token set fits in a uint8_t:

cpp
// src/json/json-parser.h:152
enum class JsonToken : uint8_t {
  NUMBER,
  STRING,
  LBRACE,
  RBRACE,
  LBRACK,
  RBRACK,
  TRUE_LITERAL,
  FALSE_LITERAL,
  NULL_LITERAL,
  WHITESPACE,
  COLON,
  COMMA,
  ILLEGAL,
  EOS
};

The parser is not a real tokeniser in the JavaScript-parser sense. There is no token stream: there is a cursor over the characters and a single next_ field holding “what character class comes next”. peek() is essentially free:

cpp
// src/json/json-parser.cc:645
template <typename Char>
JsonToken JsonParser<Char>::peek() const {
  // Check that the next token was scanned before using peek().
  DCHECK_IMPLIES(!is_at_end(),
                 GetTokenForCharacter(CurrentCharacter()) == next_);
  return next_;
}

The classification is a table lookup, and the table is generated at compile time from a constexpr function:

cpp
// src/json/json-parser.cc:45
constexpr JsonToken GetOneCharJsonToken(uint8_t c) {
  // clang-format off
  return
     c == '"' ? JsonToken::STRING :
     IsDecimalDigit(c) ?  JsonToken::NUMBER :
     c == '-' ?  JsonToken::NUMBER :
     c == '[' ?  JsonToken::LBRACK :
     c == '{' ?  JsonToken::LBRACE :
     c == ']' ?  JsonToken::RBRACK :
     c == '}' ?  JsonToken::RBRACE :
     c == 't' ?  JsonToken::TRUE_LITERAL :
     c == 'f' ?  JsonToken::FALSE_LITERAL :
     c == 'n' ?  JsonToken::NULL_LITERAL :
     c == ' ' ?  JsonToken::WHITESPACE :
     c == '\t' ?  JsonToken::WHITESPACE :
     c == '\r' ?  JsonToken::WHITESPACE :
     c == '\n' ?  JsonToken::WHITESPACE :
     c == ':' ?  JsonToken::COLON :
     c == ',' ?  JsonToken::COMMA :
     JsonToken::ILLEGAL;
  // clang-format on
}

and the table itself is a 256-entry array built by a macro that expands the 0..127 range twice:

cpp
// src/json/json-parser.cc:68
// Table of one-character tokens, by character (0x00..0xFF only).
static const constexpr JsonToken one_char_json_tokens[256] = {
#define CALL_GET_SCAN_FLAGS(N) GetOneCharJsonToken(N),
    INT_0_TO_127_LIST(CALL_GET_SCAN_FLAGS)
#undef CALL_GET_SCAN_FLAGS
#define CALL_GET_SCAN_FLAGS(N) GetOneCharJsonToken(128 + N),
        INT_0_TO_127_LIST(CALL_GET_SCAN_FLAGS)
#undef CALL_GET_SCAN_FLAGS
};

There is a second table, character_json_scan_flags, which packs three independent pieces of information into one byte per character: the escape kind (b, t, n, f, r, u, ", \\, /, or illegal), whether the character can terminate a string, and whether it can be part of a number. All three are BitFields over the same byte:

cpp
// src/json/json-parser.cc:105
constexpr uint8_t GetJsonScanFlags(uint8_t c) {
  // clang-format off
  return (c == 'b' ? EscapeKindField::encode(EscapeKind::kBackspace)
          : c == 't' ? EscapeKindField::encode(EscapeKind::kTab)
          : c == 'n' ? EscapeKindField::encode(EscapeKind::kNewLine)
          : c == 'f' ? EscapeKindField::encode(EscapeKind::kFormFeed)
          : c == 'r' ? EscapeKindField::encode(EscapeKind::kCarriageReturn)
          : c == 'u' ? EscapeKindField::encode(EscapeKind::kUnicode)
          : c == '"' ? EscapeKindField::encode(EscapeKind::kSelf)
          : c == '\\' ? EscapeKindField::encode(EscapeKind::kSelf)
          : c == '/' ? EscapeKindField::encode(EscapeKind::kSelf)
          : EscapeKindField::encode(EscapeKind::kIllegal)) |
         (c < 0x20 ? MayTerminateStringField::encode(true)
          : c == '"' ? MayTerminateStringField::encode(true)
          : c == '\\' ? MayTerminateStringField::encode(true)
          : MayTerminateStringField::encode(false)) |
         NumberPartField::encode(c == '.' ||
                                 c == 'e' ||
                                 c == 'E' ||
                                 IsDecimalDigit(c) ||
                                 c == '-' ||
                                 c == '+');
  // clang-format on
}

One byte per character, three questions answered. This is the kind of thing that only matters when you are parsing hundreds of megabytes of JSON, which is exactly the workload JSON parsers exist for.

Numbers: Smi first

JSON.parse must produce a number, and JavaScript has two number representations: Smis (tagged integers) and heap numbers (doubles). The parser tries Smi first, and only falls back to a double.

The Smi path is a hand-rolled digit loop with a hard length limit of nine digits, which is exactly the range of a 32-bit Smi:

cpp
// src/json/json-parser.cc:1824
    } else {
      const Char* smi_start = cursor_;
      static_assert(Smi::IsValid(-999999999));
      static_assert(Smi::IsValid(999999999));
      const int kMaxSmiLength = 9;
      int32_t i = 0;
      const Char* stop = cursor_ + kMaxSmiLength;
      if (stop > end_) stop = end_;
      while (cursor_ < stop && IsDecimalDigit(*cursor_)) {
        i = (i * 10) + ((*cursor_) - '0');
        cursor_++;
      }

Nine digits, accumulated in an int32_t, no allocation, no floating point. If the character after the digits is not a number part, the value is a Smi and the parse is over:

cpp
// src/json/json-parser.cc:1844
      c = CurrentCharacter();
      if (!base::IsInRange(c, 0,
                           static_cast<int32_t>(unibrow::Latin1::kMaxChar)) ||
          !IsNumberPart(character_json_scan_flags[c])) {
        // Smi.
        // TODO(verwaest): Cache?
        *result_smi = i * sign;
        return false;
      }

Only if there is a fraction or an exponent does the parser fall back to StringToDouble, and even then it re-checks whether the double happens to be integral:

cpp
// src/json/json-parser.cc:1883
    base::Vector<const Char> chars(start, cursor_ - start);
    *result_double =
        StringToDouble(chars,
                       NO_CONVERSION_FLAG,  // Hex, octal or trailing junk.
                       std::numeric_limits<double>::quiet_NaN());
    DCHECK(!std::isnan(*result_double));

    // The result might still be a smi even if it has a decimal part.
    return !DoubleToSmiInteger(*result_double, result_smi);

NO_CONVERSION_FLAG matters: StringToDouble will happily parse 0x10 or 010 as a number if you let it, and JSON forbids both. The flag says “no hex, no octal, no trailing junk” — and the parser has already validated the grammar, so the flag is belt and braces.

There is one more wrinkle: the array path avoids boxing numbers at all. While an array is being parsed, numbers are accumulated in two flat vectors, one of Smis and one of doubles, and the array’s elements kind is decided at the end:

cpp
// src/json/json-parser.cc:1368
  // Avoid allocating HeapNumbers until we really need to.
  bool saw_double = false;
  bool success = false;
  DCHECK_EQ(double_elements_.size(), 0);
  DCHECK_EQ(smi_elements_.size(), 0);
  // GetNextNonWhitespaceToken was performed by Check(JsonToken::RBRACK).
  while (peek() == JsonToken::NUMBER) {
    double current_double;
    int current_smi;
    if (ParseJsonNumberAsDoubleOrSmi(&current_double, &current_smi)) {
      saw_double = true;
      double_elements_.push_back(current_double);
    } else {

So [1,2,3] becomes a PACKED_SMI_ELEMENTS array with no HeapNumber allocations at all, and [1.5,2.5] becomes a PACKED_DOUBLE_ELEMENTS array, also with no boxing. The “double” array is built as a FixedDoubleArray, which stores raw doubles.

Strings: SIMD scanning and a two-pass decode

String scanning is the hot loop, and it is vectorised. The scanner looks for three things at once — a control character, a quote, or a backslash — using Highway (hwy), which means the code is portable across SSE, AVX, NEON and RISC-V without a macro per architecture:

cpp
// src/json/json-parser.cc:2097
  while (true) {
    if constexpr (sizeof(Char) == 2) {
      // While all characters seen so far are Latin1 (<= 0xff), also check for
      // characters > 0xff so `bits` tracks whether the string can be one-byte
      // converted.
      if (V8_LIKELY(bits <= unibrow::Latin1::kMaxChar)) {
        for (; cursor_ + (stride - 1) < end_; cursor_ += stride) {
          const auto input = hw::LoadU(tag, cursor_);
          // TODO(floitsch): use operators for the comparisons when they are
          // available on RISC-V.
          const auto result = hw::Or(
              hw::Or(hw::Lt(input, mask_0x20), hw::Eq(input, mask_quote)),
              hw::Or(hw::Eq(input, mask_backslash), hw::Gt(input, mask_0xff)));
          if (V8_LIKELY(hw::AllFalse(tag, result))) continue;
          cursor_ += hw::FindKnownFirstTrue(tag, result);
          break;
        }

The bits accumulator is a nice trick: for a two-byte source string, the parser tracks whether any character seen so far exceeds 0xff. If none has, the decoded string can be stored as a one-byte string, which halves its memory footprint. The SIMD loop checks for “greater than 0xff” in the same comparison as the escape characters, so it costs nothing extra.

The scan does not decode. It records the range of the string in the source, plus a flag saying whether it contained escapes:

cpp
// src/json/json-parser.cc:2157
    if (*cursor_ == '"') {
      uint32_t end = position();
      advance();
      uint32_t length = end - offset;
      bool convert = sizeof(Char) == 1 ? bits > unibrow::Latin1::kMaxChar
                                       : bits <= unibrow::Latin1::kMaxChar;
      return JsonString(start, length, convert, needs_internalization,
                        has_escape);
    }

The decoding happens later, in DecodeString, and it is a separate pass over the range. That is a deliberate split: the vast majority of JSON strings have no escapes, and for those the decode is a plain memcpy (called CopyChars in the source). Only strings with escapes pay for the second pass.

The escape decoder is a switch on a table-driven escape kind, and the \uXXXX case is where the surrogate pair logic lives:

cpp
// src/json/json-parser.cc:2053
      case EscapeKind::kUnicode: {
        base::uc32 value = 0;
        for (int i = 0; i < 4; i++) {
          value = value * 16 + base::HexValue(*++cursor);
        }
        if (value <=
            static_cast<base::uc32>(unibrow::Utf16::kMaxNonSurrogateCharCode)) {
          *sink++ = value;
          length--;
        } else {
          SBXCHECK_GE(length, 2);
          *sink++ = unibrow::Utf16::LeadSurrogate(value);
          *sink++ = unibrow::Utf16::TrailSurrogate(value);
          length -= 2;
        }
        break;
      }

Note what this does not do: it does not check that a lone surrogate is well-formed, and it does not combine surrogate pairs. A \uD83D\uDE00 escape produces two UTF-16 code units, exactly as the JSON spec requires. V8’s strings are UTF-16, and JSON escapes are defined in terms of UTF-16 code units, so there is nothing to combine.

The offset bookkeeping in the scanner is where the length of the decoded string is tracked while the encoded length is being scanned:

cpp
// src/json/json-parser.cc:2196
          // \uXXXX results in either 1 or 2 Utf16 characters, depending on
          // whether the decoded value requires a surrogate pair.
          offset += 5 - (value > static_cast<base::uc32>(
                                     unibrow::Utf16::kMaxNonSurrogateCharCode));

Six source characters (\uXXXX) become one or two UTF-16 units, so the decoded length is shorter than the source length. The scanner tracks that so the destination buffer can be sized exactly.

The JsonString union: a range or an index

The result of a string scan is a JsonString, and it is a tagged union with a genuinely surprising second case:

cpp
// src/json/json-parser.h:29
class JsonString final {
 public:
  JsonString()
      : start_(0),
        length_(0),
        needs_conversion_(false),
        internalize_(false),
        has_escape_(false),
        is_index_(false) {}

  explicit JsonString(uint32_t index)
      : index_(index),
        length_(0),
        needs_conversion_(false),
        internalize_(false),
        has_escape_(false),
        is_index_(true) {}

JsonString is either a range into the source buffer (start_, length_) or an array index (index_). Which one it is depends on where it came from. A JSON object key that looks like an array index (“0”, “1”, “42”) is not a string at all — it is an element index, and the parser must know that so it can put the value in the elements backing store rather than in a named property.

ScanJsonPropertyKey detects this with a fast scan over the digits, and it is careful to handle the escaped form ("1" written as "\u0031"):

cpp
// src/json/json-parser.cc:680
JsonString JsonParser<Char>::ScanJsonPropertyKey(JsonContinuation* cont) {
  {
    DisallowGarbageCollection no_gc;
    const Char* start = cursor_;
    base::uc32 first = CurrentCharacter();
    if (first == '\\' && NextCharacter() == 'u') first = ScanUnicodeCharacter();
    if (IsDecimalDigit(first)) {
      if (first == '0') {
        if (NextCharacter() == '"') {
          advance();
          // Record element information.
          cont->elements++;
          DCHECK_LE(0, cont->max_index);
          return JsonString(0);
        }
      } else {
        uint32_t index = first - '0';
        while (true) {
          cursor_ = std::find_if(cursor_ + 1, end_, [&index](Char c) {
            return !IsDecimalDigit(c) || !TryAddArrayIndexChar(&index, c);
          });

TryAddArrayIndexChar exists so that an index that overflows 32 bits stops being an index and becomes a string again — {"4294967296": 1} is a named property, not an element.

Internalization: a budget, not a policy

Property keys must be internalized strings, because object property names are compared by pointer. Values usually are not. V8 draws the line with a heuristic and a hard budget:

cpp
// src/json/json-parser.cc:1945
  if (!string.has_escape()) {
    bool should_internalize = string.internalize();
    // For non-escaped strings, try to internalize the following candidates:
    // 1. Property keys (string.internalize() is true in that case).
    // 2. Short one-byte strings. If we have a budget for heuristic,
    //    we can consider inserting them. Otherwise, at least try to reuse
    //    existing internalized strings without inserting new ones.
    constexpr int kMaxInternalizedStringValueLength = 10;
    if (!should_internalize && sizeof(Char) == 1 &&
        string.length() < kMaxInternalizedStringValueLength) {
      if (remaining_heuristic_internalizations_ > 0) {
        --remaining_heuristic_internalizations_;
        should_internalize = true;
      } else {

The budget is a flag with a default of 100 000:

cpp
// src/flags/flag-definitions.h:882
DEFINE_UINT(json_parse_max_heuristically_internalized_strings, 100'000,
            "Maximum number of strings that are eagerly internalized during "
            "JSON.parse() based on heuristics. 0 means only strings that "
            "must be internalized e.g. property keys will be internalized.")

The reasoning is a memory/value trade-off. If a JSON payload contains 100 000 distinct short strings, internalizing all of them adds 100 000 entries to the string table, and the string table is a weak reference — those strings can be collected, but the table entries and the hash computation are not free.

Once the budget is exhausted, the parser does something cleverer than giving up: it looks up the string in the string table without inserting it, and reuses the existing internalized copy if there is one:

cpp
// src/json/json-parser.cc:1966
        } else {
          base::Vector<const Char> chars(chars_ + string.start(),
                                         string.length());
          SequentialStringKey<Char> key(chars, HashSeed(isolate_),
                                        string.needs_conversion());
          auto existing =
              isolate_->string_table()->TryLookupKey(isolate_, &key);
          if (existing) return handle(*existing.value(), isolate_);
        }

So a payload of {"status":"ok","status":"ok","status":"ok",...} costs one internalization and then a hash lookup per subsequent occurrence, instead of one internalization per occurrence. The parser is not internalizing because it is lazy; it is internalizing because it is repeated.

The shape-prediction trick

Here is the part worth reading the source for. When the parser is inside a JSON array and has just parsed an object, it takes that object’s map and uses it as feedback for the next object:

cpp
// src/json/json-parser.cc:1442
  Handle<Object> value;
  if (V8_UNLIKELY(!ParseJsonValueRecursive().ToHandle(&value))) return {};
  element_stack_.emplace_back(value);
  while (Check<JsonToken::COMMA>()) {
    Handle<Map> feedback;
    if (IsJSObject(*value)) {
      Tagged<Map> maybe_feedback = Cast<JSObject>(*value)->map();
      // Don't consume feedback from objects with a map that's detached
      // from the transition tree.
      if (!maybe_feedback->IsDetached(isolate_)) {
        feedback = handle(maybe_feedback, isolate_);
      }
    }
    if (V8_UNLIKELY(!ParseJsonValueRecursive(feedback).ToHandle(&value))) {
      return {};
    }
    element_stack_.emplace_back(value);
  }

The first element of the array is parsed the slow way. The second element is parsed with the first element’s map as a prediction: “this object will probably have the same fields in the same order”. The third gets the second’s map, and so on. If the prediction holds — same keys, same order — the object can be built in one shot, with the fields written directly into the object, bypassing the generic transition machinery entirely.

The prediction is checked in ParseJsonObjectProperties with a byte comparison against the expected key, and the check is a pointer-and-length comparison against the internalized key, not a string comparison:

cpp
// src/json/json-parser.cc:1096
template <typename Char>
bool JsonParser<Char>::FastKeyMatch(const uint8_t* key_chars,
                                    uint32_t key_length,
                                    JsonString scanned_key) {
  return key_length == scanned_key.length() &&
         CompareCharsEqual(key_chars, chars_ + scanned_key.start(), key_length);
}

And when the prediction holds all the way through, BuildJsonObject allocates the object with the feedback map and writes the fields directly:

cpp
// src/json/json-parser.cc:816
  // Fast path for homogeneous object arrays:
  // If ParseJsonObjectProperties<FastIterableState::kJsonFast> matched all
  // property keys against the feedback map in exact sequence without any
  // trailing properties (fast_keys_matched == true) and the object contains no
  // indexed array elements (cont.elements == 0), we can bypass
  // JSDataObjectBuilder entirely.

The “no indexed array elements” condition matters: the fast path sets the elements backing store to the empty fixed array, and the map’s elements kind has to be compatible with that. And every value is checked against the representation the feedback map expects, with a bail-out to the generic builder if a value does not fit:

cpp
// src/json/json-parser.cc:851
      Tagged<Object> value = *property_stack_[start + i].value;
      Representation rep = details.representation();
      if (!Object::FitsRepresentation(value, rep)) {
        fast_path_valid = false;
        break;
      }

This is the same idea as the inline cache that remembers a map, applied to a data format: the shape of the previous element predicts the shape of the next one, and if the prediction holds you skip the expensive generic path.

The reviver: a second pass, with snapshots

JSON.parse(text, reviver) runs the reviver after the parse, in a separate walk over the already-built object graph. The walk is JsonParseInternalizer, and it is where user code finally runs.

The interesting part is the snapshot mechanism. The spec says that if the reviver replaces the holder’s value with something else, the source context argument is not passed. To implement that, the parser records, for every primitive value, the source text it came from, and for every object, a table of its children’s source texts:

cpp
// src/json/json-parser.cc:1476
  // When should_track_json_source is true, we use val_node to record current
  // JSON value's parse node.
  //
  // For primitive values, the val_node is the source string of the JSON value.
  //
  // For JSObject values, the val_node is an ObjectHashTable in which the key is
  // the property name and the first value is the property value's parse node.

Collecting all that is expensive, so V8 only does it when the reviver actually needs it. The check is on the reviver’s arity, and it is documented in the source:

cpp
// src/json/json-parser.cc:2229
  bool collect_source_strings = IsCallable(*reviver);
  if (IsJSFunction(*reviver)) {
    Handle<SharedFunctionInfo> reviver_sfi(Cast<JSFunction>(*reviver)->shared(),
                                           isolate);
    if (reviver_sfi->CanOnlyAccessFixedFormalParameters() &&
        reviver_sfi->internal_formal_parameter_count_without_receiver() < 3) {
      // The reviver function has only one or two arguments, so we don't need
      // to collect the information on the source for each primitive value.
      //
      // Note that on the first run, we don't get here because the optimizers
      // have not set enough information on the shared function info.  So we
      // will do the slow JSON parsing initially until the compiler has
      // optimized more.
      collect_source_strings = false;
    }
  }

That comment is a small lesson in how V8 features interact: the feedback has to be warm before the optimisation kicks in, so the first few calls to JSON.parse with a reviver pay the full price. The optimisation is driven by TurboFan’s inference about the reviver’s formal parameters, not by the parser.

The snapshot check itself is a SameValue comparison against the value the parser produced:

cpp
// src/json/json-parser.cc:190
  ReviverMode reviver_mode;
  if (initial_reviver_mode == kWithSource &&
      !Object::SameValue(*value, *snapshot)) {
    reviver_mode = NoSource(initial_reviver_mode);
  } else {
    reviver_mode = initial_reviver_mode;
  }

JSON.stringify: two implementations

The stringifier has the same two-speed structure, and the fast path is gated on a flag and on the absence of a replacer or indent:

cpp
// src/json/json-stringifier.cc:3582
bool CanUseFastStringifier(DirectHandle<JSAny> replacer,
                            DirectHandle<Object> gap) {
  // TODO(pthier): Support gap on fast-path.
  return v8_flags.json_stringify_fast_path && IsUndefined(*replacer) &&
         IsUndefined(*gap);
}

The fast stringifier (FastJsonStringifier) is a completely separate implementation with its own buffer, its own continuation stack and its own escape logic. It only handles objects whose prototype is the pristine Object.prototype or Array.prototype and whose map has no “interesting properties” (that is, no toJSON):

cpp
// src/json/json-stringifier.cc:2371
V8_INLINE bool CanFastSerializeJSArrayFastPath(Tagged<JSArray> object,
                                               Tagged<HeapObject> initial_proto,
                                               Isolate* isolate) {
  // Check if the prototype is the initial array prototype without interesting
  // properties (toJSON).
  Tagged<Map> map = object->map();
  if (V8_UNLIKELY(map->may_have_interesting_properties())) return false;
  Tagged<HeapObject> proto = map->prototype();
  // Note: This will also fail for sub-objects in different native contexts.
  // This should be rare and bailing-out to the slow-path is fine.
  return V8_LIKELY(proto == initial_proto);
}

The “no user code” guarantee is exactly this check. The fast path never calls toJSON, never reads a getter, never coerces a key with toString — because it only runs on objects that provably have none of those. The slow path calls all of them:

cpp
// src/json/json-stringifier.cc:990
  DirectHandle<JSAny> initial_value = object;
  if (!IsSmi(*object)) {
    InstanceType instance_type =
        Cast<HeapObject>(*object)->map()->instance_type();
    if ((InstanceTypeChecker::IsJSReceiver(instance_type) &&
         MayHaveInterestingProperties(isolate_, Cast<JSReceiver>(*object))) ||
        InstanceTypeChecker::IsBigInt(instance_type)) {
      if (!need_stack_ && stack_nesting_level_ > 0) {
        need_stack_ = true;
        return NEED_STACK;
      }
      need_stack_ = true;
      ASSIGN_RETURN_ON_EXCEPTION_VALUE(
          isolate_, object, ApplyToJsonFunction(object, key), EXCEPTION);
    }
  }

The escaping of string contents is vectorised too, with both a SWAR (32/64-bit-at-a-time) path and a SIMD path:

cpp
// src/json/json-stringifier.cc:3521
template <typename Char>
template <typename SrcChar>
bool FastJsonStringifier<Char>::AppendStringSIMD(
    const SrcChar* chars, size_t length,
    const DisallowGarbageCollection& no_gc) {

The SIMD loop tests four things at once — control characters, the quote, the backslash, and (for two-byte strings) the surrogate range:

cpp
// src/json/json-stringifier.cc:3547
    const auto has_lt_0x20 = hw::Lt(input, mask_0x20);
    const auto has_0x22 = hw::Eq(input, mask_0x22);
    const auto has_0x5c = hw::Eq(input, mask_0x5c);
    auto result = hw::Or(hw::Or(has_lt_0x20, has_0x22), has_0x5c);
    if constexpr (sizeof(SrcChar) == sizeof(base::uc16)) {
      // Checks input >= 0xd800 && input <= 0xdfff.
      const auto has_surrogate =
          hw::Eq(hw::And(input, mask_0xf800), mask_0xd800);
      result = hw::Or(result, has_surrogate);
    }

Lone surrogates have to be escaped in JSON output (\udXXX), because JSON text must be valid Unicode, and a lone surrogate is not. That is why the surrogate check is in the vectorised loop at all.

One more small thing worth stealing: the stringifier caches the escaped form of property keys, because a JSON array of objects with the same keys repeats the same key string over and over. The cache is a 64-entry direct-mapped table indexed by the key’s pointer, and it is cleared on every GC because the GC can move strings:

cpp
// src/json/json-stringifier.cc:330
  //
  // - Internalized, sequential, one-byte string
  // - Contains no characters which need escaping
  //
  // This can be helpful because it's common for JSON to have lists of similar
  // objects. Since property keys are internalized, we will see identical key
  // pointers again and again, and we can use a fast path to copy those keys to
  // the output. However, strings can be externalized any time JS runs, so the
  // caller is responsible for checking whether a string is still the expected
  // type. This cache is cleared on GC, since the GC could move those strings.
  // Using Handles for the cache has been tried, but is too expensive to set up
  // when JSON.stringify is called for tiny inputs.

Proxies and the guarantee

JSON.parse never produces a proxy and never calls user code while parsing. It builds plain objects and plain arrays, and the only JavaScript that can run during a JSON.parse is (a) the toString coercion of the argument, which happens before the parser starts, and (b) the reviver, which happens after the whole graph exists.

Proxies are a stringifier problem, and they are handled on the slow path:

cpp
// src/json/json-stringifier.cc:1101
        // Go to slow path for global proxy and objects requiring access checks.
        if (deferred_string_key) SerializeDeferredKey(comma, key);
        if (InstanceTypeChecker::IsJSProxy(instance_type)) {
          return SerializeJSProxy(Cast<JSProxy>(object), key);
        }

SerializeJSProxy calls Object::IsArray and then reads the length through the proxy, which means the proxy’s traps run, which means user code runs. If the proxy has been revoked in the meantime, the trap machinery throws:

cpp
// src/common/message-template.h:319
    T(ProxyRevoked, "Cannot perform '%' on a proxy that has been revoked")       \

The interesting detail is where the check lives. It is not in the JSON code at all. The JSON stringifier just calls Object::IsArray and Object::GetLengthFromArrayLike; the proxy implementation is what notices that the handler is no longer a JSReceiver and throws. The JSON code has no idea that proxies can be revoked — it only knows that some objects need the slow path.

Trade-offs

What it buys. JSON.parse of a 50 MB payload runs at a few hundred MB/s, which is roughly an order of magnitude faster than the equivalent JavaScript eval or Function construction, because none of the JavaScript machinery is involved. The SIMD string scanner, the Smi-first number path, the shape prediction and the internalization budget are each worth a large factor on their own.

What it costs.

  • A second implementation. There are two JSON parsers (uint8_t and uint16_t), two JSON stringifiers (fast and slow), and a JsonParser in src/torque/ls/ that is a third, unrelated JSON parser for the Torque language server. Every JSON grammar change has to be made in at least the two real ones.
  • Heuristics with cliffs. The internalization budget is a cliff: the 100 001st short string behaves differently from the 100 000th. The shape prediction is a cliff: an array of objects where one object has an extra key throws the prediction away for that element.
  • Warm-up.* The reviver fast path depends on TurboFan having inferred the reviver’s arity. Until then, every JSON.parse with a reviver collects the full source text of every value.
  • The fast stringifier does less. JSON.stringify with a toJSON method, a replacer function, an indent, a getter, or a proxy all fall off the fast path. The fast path is not “faster at the same thing”; it is “the same thing, for a restricted class of inputs”.

The bottom line

V8’s JSON parser is not a special case of the JavaScript parser. It is a separate, hand-written, table-driven machine that makes three specific decisions differently from the JavaScript parser: it keeps a raw pointer into the source instead of building tokens, it scans strings with SIMD instead of a character loop, and it predicts object shapes from the previous object rather than from a call-site feedback vector.

The mental model to keep: the JSON parser is a shape-aware allocator that happens to read text. The interesting part is not the grammar — that is trivial — but the decisions about what to allocate and when to believe the input will look like the last one.

The thing people get wrong

“JSON.parse is fast because it is a small grammar” is half true. The grammar is small, but the values are not: a JSON payload is a big graph of objects and arrays, and the cost of building that graph dominates. The parser’s real trick is not the scanning — it is that [{"a":1},{"a":2},{"a":3}] costs about the same as {"a":1}, because the second and third objects are built from the first object’s map.

And the corollary: JSON.parse is not a way to avoid allocation. It allocates a full object graph, and the objects it allocates are ordinary V8 objects with ordinary maps, ordinary property backing stores and ordinary strings. The only thing it avoids is running JavaScript.

V8InternalsJSONParser