Skip to content

V8's JSON.parse: source anatomy, techniques, and what a pure-JS YAML parser can borrow

Produced by a multi-agent research session (Claude Code). All local numbers were measured on the session container — Node v22.22.2 / V8 12.4, 4-vCPU Xeon 2.80 GHz, 16 GB RAM; absolute MB/s are machine-specific, ratios are the durable signal. Referenced scratchpad/*.mjs scripts were session throwaways and are not committed.

Snapshots of github.com/v8/v8 branch main fetched 2026-07-12 to /tmp/claude-0/-home-user-lightning-yaml/5b5afb90-7c63-5a56-af0b-8798d3e9b488/scratchpad/v8json/ (json-parser.h, json-parser.cc, builtins-json.cc, js-data-object-builder.h, js-data-object-builder-inl.h). All file:line cites below refer to these snapshots.

  • Engine, language, size: JSON.parse is implemented in V8 (not Blink), in C++, at src/json/json-parser.h (542 lines) and src/json/json-parser.cc (2259 lines). The parser is template <typename Char> class JsonParser final (json-parser.h:208-209). Contrary to the common belief that it’s “mostly in the .h”, today the bulk is in the .cc, made possible by explicit template instantiation: template class JsonParser<uint8_t>; template class JsonParser<uint16_t>; (json-parser.cc:2255-2256, declared extern at json-parser.h:536-537).
  • Builtin wiring: src/builtins/builtins-json.cc defines BUILTIN(JsonParse) — it ToStrings + String::Flattens the input, then dispatches to JsonParser<uint8_t>::Parse or JsonParser<uint16_t>::Parse based on String::IsOneByteRepresentationUnderneath (builtins-json.cc:17-31). JsonStringify, JsonRawJson, JsonIsRawJson are wired in the same file (builtins-json.cc:34-55).
  • Object-construction machinery was recently extracted to src/objects/js-data-object-builder{,-inl}.h (JSDataObjectBuilder, copyright 2026 — js-data-object-builder.h:1-5), which json-parser.cc includes (json-parser.cc:28).
  • Chromium vendors V8: Chromium’s DEPS pins 'v8_revision': '89413eb...' and maps 'src/v8' to chromium_git + '/v8/v8.git' @ v8_revision (https://raw.githubusercontent.com/chromium/chromium/main/DEPS lines 334, 3241-3242). Node embeds V8 the same way (deps/v8).

2. Concrete techniques, with code evidence, and JS-replicability verdicts

Section titled “2. Concrete techniques, with code evidence, and JS-replicability verdicts”

T1. Templating on character width (one-byte vs two-byte input)

Section titled “T1. Templating on character width (one-byte vs two-byte input)”

The whole parser is compiled twice, JsonParser<uint8_t> and JsonParser<uint16_t>; kIsOneByte = sizeof(Char) == 1 (json-parser.h:490). Dispatch happens once at entry (builtins-json.cc:27-30). The one-byte instantiation gets the SIMD string scan (T8); the two-byte one gets a scalar std::find_if that also accumulates a bits mask to decide if the string content actually needs two-byte output (json-parser.cc:2128-2136, 2149-2150 — needs_conversion computed from bits). Verdict: partially. JS code cannot see string representation. Approximations: (a) parse a Node Buffer/Uint8Array (latin1/UTF-8 bytes) with a byte-oriented core — the true analogue of the uint8_t instantiation — but then producing JS strings costs a decode step; (b) stick to charCodeAt loops, which V8’s JIT compiles with internal one-byte fast paths anyway. You can’t have two specializations selected by the engine, but a single charCodeAt-based scanner is close for one-byte-heavy YAML.

Two 256-entry constexpr tables: one_char_json_tokens[256] maps a byte to a token (json-parser.cc:44-75), used by GetTokenForCharacter (json-parser.cc:628-632) and the whitespace-skipping std::find_if loop (json-parser.cc:643-655). character_json_scan_flags[256] (json-parser.cc:104-137) packs 3 bitfields per char — escape kind (3 bits), may-terminate-string, is-number-part (json-parser.cc:88-101) — consulted in string scanning (json-parser.cc:2126, 2134), number scanning (json-parser.cc:1823, 1857), and escape decoding (json-parser.cc:2032). Verdict: yes, fully replicable. const FLAGS = new Uint8Array(256) indexed by charCodeAt is idiomatic fast JS; branchless flag tests (flags[c] & IS_NUMBER_PART) work identically. This is one of the highest-value, lowest-effort wins over js-yaml (which classifies chars via function calls/comparisons per char).

T3. Recursion vs explicit continuation stack — V8 uses BOTH

Section titled “T3. Recursion vs explicit continuation stack — V8 uses BOTH”
  • Default path is recursive: ParseJson calls ParseJsonValueRecursive() unless reviver-source-tracking is needed (json-parser.cc:606-613; 1032-1072).
  • Every recursive object/array entry does a StackLimitCheck; on imminent overflow it falls back to the iterative parser ParseJsonValue<false> with an explicit std::vector<JsonContinuation> cont_stack (json-parser.cc:1277-1283, 1338-1343, 1456-1461). The v7.6 rewrite (https://v8.dev/blog/v8-release-76) originally made it fully iterative (“manages its own stack, limited only by available memory”); the recursive fast path was later re-added for speed.
  • Values/keys are buffered in flat stacks, not built incrementally: SmallVector<Handle<Object>> element_stack_; SmallVector<JsonProperty> property_stack_; SmallVector<double> double_elements_; SmallVector<int> smi_elements_ (json-parser.h:516-519), so the final object/array can be allocated exactly-sized in one shot. Verdict: yes. Recursion + a depth counter falling back to an explicit stack is directly expressible in JS; so is buffering keys[]/values[] in reusable flat arrays and constructing the object only at }.

T4. String handling: deferred materialization, escape fast path, internalization

Section titled “T4. String handling: deferred materialization, escape fast path, internalization”
  • Zero-copy scan record: ScanJsonString never builds a string; it returns a 12-byte JsonString{start, length, needs_conversion, internalize, has_escape} (json-parser.h:29-98; json-parser.cc:2089-2210). Materialization is deferred to MakeString (json-parser.cc:1942-2012) — keys that match feedback are never materialized at all (T6).
  • Escape fast path: no-escape strings are bulk-copied (CopyChars, json-parser.cc:1924-1927) or internalized directly as a substring of the source with no intermediate copy (InternalizeSubString, json-parser.cc:1992-1995). Escaped strings decode via std::find(cursor, ..., '\\') runs + table-dispatched escape switch (json-parser.cc:2016-2086).
  • Key internalization + string-table probing: property keys are internalized (deduped in the isolate string table). Short (<10 chars) one-byte value strings are heuristically internalized under a flag budget json_parse_max_heuristically_internalized_strings (json-parser.cc:364-365, 1962-1986); once budget is spent it still calls string_table()->TryLookupKey to reuse existing internalized strings without inserting new ones (json-parser.cc:1969-1984).
  • Hint reuse: MakeString(string, hint) compares raw chars against the expected key from map feedback and returns the existing internalized string on match — zero allocation (json-parser.cc:1988-1991; NamedPropertyIterator::GetKey json-parser.cc:771-774).
  • Single-char strings come from a cached table (LookupSingleCharacterStringFromCode, json-parser.cc:1952); empty string is a root (json-parser.cc:1944). Verdict: partially. Directly replicable in JS: record (start,end,hasEscape) and defer .slice(); slice of ≥13 chars is O(1) in V8 (SlicedString), so the no-escape fast path is nearly free; decode escapes only when seen; keep your own Map<string,string>-style key cache per document so repeated keys are ===-identical (helps downstream hidden-class reuse and GC). Not replicable: touching the engine string table; true internalization happens implicitly only when a string is used as a property key. A JS single-char string cache (String.fromCharCode memoized array) is trivially replicable.

T5. Number fast paths: SMI without double parsing

Section titled “T5. Number fast paths: SMI without double parsing”

ParseJsonNumberAsDoubleOrSmi (json-parser.cc:1802-1903): up to kMaxSmiLength = 9 digits are accumulated inline as int32_t i = i*10 + (c-'0') (json-parser.cc:1835-1861); if the next char is not a number-part per the flags table, the value is returned as a Smi — no StringToDouble, no heap allocation (Smi is a tagged immediate; json-parser.cc:1793-1800). Only numbers with ./e/long digit runs hit StringToDouble (json-parser.cc:1893-1897), and even the double result is demoted back to Smi if integral (DoubleToSmiInteger, json-parser.cc:1901). Pure-number arrays skip Handles entirely: raw int/double vectors filled in a tight loop, then bulk-copied into a PACKED_SMI_ELEMENTS or PACKED_DOUBLE_ELEMENTS backing store (json-parser.cc:1353-1424). Verdict: yes, mostly. Hand-rolled code - 48 accumulation for short integer runs is exactly expressible in JS and yields Smis automatically (int32 values are Smis in V8). For fractional/exponent numbers, call Number(s.slice(...))/parseFloat for correctness (writing your own strtod in JS is a correctness trap); the slice allocation is the unavoidable tax. Arrays stay PACKED_SMI/PACKED_DOUBLE automatically if you only append numbers and never create holes.

T6. Object construction via map/hidden-class transition feedback — THE big one

Section titled “T6. Object construction via map/hidden-class transition feedback — THE big one”
  • Feedback source: while parsing an array, the map (hidden class) of the previous element is passed as feedback for the next element: json-parser.cc:1430-1440 (recursive) and 1680-1692 (iterative). So [{...},{...},...] of homogeneous objects gets cheaper with each element.
  • Raw-byte key matching: with feedback, ParseJsonObjectProperties<kJsonFast> compares the raw input bytes at the cursor against the feedback map’s next descriptor key — FastKeyMatch is a memcmp plus cursor_[len] == '"' (json-parser.cc:1074-1087, used at 1150-1171). On match it skips key scanning, hashing, and string creation entirely (cursor_ += key_length + 1). Descriptor arrays carry a tri-state FastIterableState (kUnknown/kJsonFast/kJsonSlow) cached across parses (json-parser.cc:1296-1324, 1230-1269).
  • Direct layout allocation: if all keys matched the feedback map in exact sequence (cont.fast_keys_matched, json-parser.h:252-256) and there are no index keys, BuildJsonObject bypasses the generic builder: verify each value FitsRepresentation/field type of the feedback map’s descriptors, then allocate straight from the final map (NewJSObjectFromMap) and write in-object fields raw with SKIP_WRITE_BARRIER (json-parser.cc:804-896).
  • Transition walking otherwise: JSDataObjectBuilder::TryFastTransitionToPropertyKey uses TransitionsAccessor::ExpectedTransition(key_chars) to find an existing map transition by raw key characters (js-data-object-builder-inl.h:382-397), or walks the hinted expected-final-map’s descriptors (js-data-object-builder-inl.h:370-380, 427-459), so repeated shapes get their final hidden class without dictionary lookups; fields are then written in-object with folded mutable-HeapNumber allocation (js-data-object-builder-inl.h:303-358).
  • Array element-kind selection: BuildJsonArray scans buffered values and picks PACKED_SMI_ELEMENTS / PACKED_DOUBLE_ELEMENTS / PACKED_ELEMENTS, filling a FixedArray or FixedDoubleArray directly (json-parser.cc:956-986). Per the v7.6 blog, objects are allocated with exactly the needed space (up to 128 named properties) and elements pick flat-array vs dictionary by whichever is smaller (json-parser.cc:903-938; https://v8.dev/blog/v8-release-76). Verdict: impossible directly / partially in effect. JS cannot touch Maps, descriptors, or write barriers. But the effects are substantially reproducible: (1) hidden-class reuse is automatic if you insert the same keys in the same order into {} — the engine walks the same transition tree your parser would; (2) the raw-key-match trick IS replicable: cache the previous mapping’s key strings + their source byte patterns, and when the next mapping’s key bytes match, reuse the cached key string (skip slice+dedup) — this is the single most transferable idea for YAML streams of homogeneous mappings; (3) ===-identical key strings (via your own key cache) make the engine’s KeyedStoreIC/transition lookups cheaper; (4) element kinds are managed automatically — just keep arrays packed (append-only, no holes, exact types). The extreme version — codegen a new Function('a','b','return {x:a,y:b}') per observed shape so allocation becomes a monomorphic object literal — is possible in JS and is how some fast serializers work, at the cost of complexity and CSP-unfriendliness.

HandleScope per continuation (json-parser.h:249); SmallVector<T,16> inline-storage stacks (json-parser.h:230-231); HighAllocationThroughputScope tells the platform/GC a burst is coming (json-parser.cc:2216); folded HeapNumber pre-allocation so object + fields initialize without intervening GC (json-parser.cc:857-895); SKIP_WRITE_BARRIER stores justified by “most recent young allocation” (json-parser.cc:891-892, 930-932); cached raw chars_ pointer into the source string with a GC-epilogue callback that re-fixes the pointer if the string moved (json-parser.cc:384-401; json-parser.h:469-487). Verdict: impossible from JS (all engine-internal). The transferable intent — minimize garbage — maps to: reuse scratch arrays across parse calls, avoid closures/temporary objects in hot loops, avoid intermediate substring churn. That’s how you win the peak-RSS benchmark against js-yaml, which allocates heavily per token.

Yes — real SIMD, via Google Highway (#include "hwy/highway.h", json-parser.cc:9). In the one-byte instantiation, ScanJsonString scans 16 bytes/iteration for c < 0x20 | c == '"' | c == '\\' using hw::LoadU/Lt/Eq/Or/AllFalse/FindKnownFirstTrue (json-parser.cc:2090-2127), with a scalar flags-table tail loop. Not simdutf, and only strings — whitespace skipping and number scans remain scalar std::find_if (json-parser.cc:647-652, 1787-1790). Verdict: impossible directly from JS (no JS SIMD; Wasm SIMD exists but JS-string↔Wasm-memory copies typically eat the gains for parse-to-JS-objects workloads). Practical JS substitute: String.prototype.indexOf(char, from) is engine-native (memchr-class, vectorized) — using indexOf('\n') / indexOf('"') / indexOf('\\') to skip long runs from JS effectively borrows V8’s native scanning, and is the closest legal analogue for YAML line/scalar scanning.

3. v8.dev material + native state of the art

Section titled “3. v8.dev material + native state of the art”
  • “The cost of JavaScript in 2019” (https://v8.dev/blog/cost-of-javascript-2019, Osmani/Bynens): JSON.parse('…') is “much faster to parse, compile, and execute compared to an equivalent JavaScript literal — not just in V8 (1.7× as fast), but in all major JavaScript engines”; rule of thumb: use it for objects ≥10 kB; also notes object literals risk being parsed twice (preparse + lazy parse). Companion talk: Mathias Bynens, “Faster apps with JSON.parse”, Chrome Dev Summit 2019 (linked from the post); feature explainer: https://v8.dev/features/subsume-json (JSON ⊂ ECMAScript).
  • “V8 release v7.6” (https://v8.dev/blog/v8-release-76, 2019): JSON parser overhauled — “up to 2.7× faster parsing of data served by popular web pages”; switched from native-stack recursion to an iterative parser with its own stack; buffers properties before creating the final object; allocates objects with exactly the needed space (up to 128 named properties); picks flat-array vs dictionary elements backing store by size; arrays exactly fit element count. (These all correspond to code cited in T3/T6 above.)
  • simdjson (native state of the art, for calibration): “Parsing gigabytes of JSON per second” — README claims 4× faster than RapidJSON and 25× faster than JSON for Modern C++; minify at 6 GB/s, UTF-8 validation at 13 GB/s, NDJSON at 3.5 GB/s multithreaded; GB/s-class parsing on commodity cores (https://github.com/simdjson/simdjson README lines 6-16, 137, 156). Paper: Langdale & Lemire, “Parsing Gigabytes of JSON per Second”, VLDB J 28(6) 2019, https://arxiv.org/abs/1902.08318 (~2.5 GB/s in the paper’s era). Note simdjson parses to its own DOM/tape — it does not pay V8’s cost of materializing JS heap objects, which is why JSON.parse (which must) is far below GB/s and why object-construction cost (T6), not scanning, is usually the dominant term for a JS-object-producing parser.

4. Priority-ordered takeaways for lightning-yaml (pure JS)

Section titled “4. Priority-ordered takeaways for lightning-yaml (pure JS)”
  1. Flags tables (T2) — Uint8Array(256) char-class tables for YAML’s indicator/space/scalar-safe classes: fully replicable, big win.
  2. Deferred, zero-copy scalars (T4) — record offsets, slice lazily, escape-free fast path: fully replicable.
  3. Homogeneous-mapping key feedback (T6-lite) — cache previous mapping’s keys; byte-compare next mapping’s keys against them; reuse === key strings and identical insertion order so hidden classes are shared: replicable in effect, the highest-leverage structural idea.
  4. Inline int fast path + number buffering (T5) — replicable; avoids Number()+slice for the common integer case.
  5. Recursion with buffered key/value flat stacks (T3) — replicable; enables exact-size object/array construction.
  6. Native scan borrowing (T8 substitute)indexOf for long-run skipping (line ends, doc markers, quote/backslash search).
  7. Engine-internal items (handles, write barriers, string table, real SIMD, map surgery) — not available; compensate by minimizing allocations, which also directly targets the peak-RSS metric vs js-yaml.
  • JSON.parse lives in V8 (C++), not Blink: template class JsonParser in src/json/json-parser.h (542 lines) + json-parser.cc (2259 lines); bulk of code is in the .cc via explicit instantiation JsonParser<uint8_t>/<uint16_t> (snapshot json-parser.cc:2255-2256; fetched from raw.githubusercontent.com/v8/v8/main 2026-07-12)
  • Builtin wiring: BUILTIN(JsonParse) in src/builtins/builtins-json.cc flattens the string then dispatches JsonParser<uint8_t> vs <uint16_t> on String::IsOneByteRepresentationUnderneath (builtins-json.cc:17-31); Chromium vendors V8 via DEPS ‘src/v8’ pinned to v8_revision (chromium DEPS lines 334, 3241-3242)
  • Char-class lookup tables: one_char_json_tokens[256] (json-parser.cc:68-75) and character_json_scan_flags[256] packing escape-kind/may-terminate-string/is-number-part bitfields (json-parser.cc:88-137) — fully replicable in JS as Uint8Array(256) + charCodeAt
  • Parser is recursive by default (ParseJsonValueRecursive, json-parser.cc:1032-1072) with StackLimitCheck falling back to an iterative continuation-stack parser ParseJsonValue (json-parser.cc:1277-1283, 1456-1461); keys/values buffered in flat SmallVector stacks incl. raw smi_elements_/double_elements_ (json-parser.h:516-519)
  • Strings are scanned zero-copy into JsonString{start,length,needs_conversion,internalize,has_escape} records (json-parser.h:29-98) and materialized lazily in MakeString; no-escape strings are internalized as substrings without copy; short (<10 char) one-byte value strings heuristically internalized under a budget, then string-table TryLookupKey reuse only (json-parser.cc:1942-2012)
  • Number fast path: up to 9 digits accumulated inline as int32 and returned as Smi with no double parse or heap allocation (kMaxSmiLength=9, json-parser.cc:1835-1861); StringToDouble only for ‘.’/‘e’/long runs, with DoubleToSmiInteger demotion (json-parser.cc:1893-1901)
  • Hidden-class feedback is the big one: previous array element’s map is passed as feedback (json-parser.cc:1430-1440); FastKeyMatch memcmps raw input bytes against the feedback map’s descriptor keys, skipping key scan/hash/alloc (json-parser.cc:1074-1087, 1150-1171); if all keys match in sequence, the object is allocated directly from the final map with SKIP_WRITE_BARRIER field stores (json-parser.cc:804-896); otherwise JSDataObjectBuilder walks TransitionsAccessor::ExpectedTransition(key_chars) (js-data-object-builder-inl.h:382-397)
  • Real SIMD exists: ScanJsonString uses Google Highway (hwy/highway.h, json-parser.cc:9) to scan 16 bytes/iter for ‘“’/‘\’/control chars in the one-byte instantiation (json-parser.cc:2090-2127); JS substitute is String.prototype.indexOf for native long-run scanning
  • v8.dev primary sources: cost-of-javascript-2019 says JSON.parse is 1.7x as fast as an equivalent JS object literal in V8 (faster in all major engines, rule of thumb >=10kB); v8-release-76 documents the 2019 rewrite: up to 2.7x faster, iterative parser, property buffering, exact-size allocation up to 128 named properties
  • simdjson README (github.com/simdjson/simdjson) claims GB/s-class parsing on commodity cores, 4x RapidJSON, minify 6 GB/s, UTF-8 validate 13 GB/s, NDJSON 3.5 GB/s; Langdale & Lemire VLDB J 2019 (arxiv 1902.08318) ~2.5 GB/s — context: it does not materialize JS objects, which is the dominant cost for JSON.parse and for any YAML-to-JS-object parser