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/*.mjsscripts 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.
1. Where the real implementation lives
Section titled “1. Where the real implementation lives”- Engine, language, size: JSON.parse is implemented in V8 (not Blink), in C++, at
src/json/json-parser.h(542 lines) andsrc/json/json-parser.cc(2259 lines). The parser istemplate <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.ccdefinesBUILTIN(JsonParse)— itToStrings +String::Flattens the input, then dispatches toJsonParser<uint8_t>::ParseorJsonParser<uint16_t>::Parsebased onString::IsOneByteRepresentationUnderneath(builtins-json.cc:17-31).JsonStringify,JsonRawJson,JsonIsRawJsonare 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
DEPSpins'v8_revision': '89413eb...'and maps'src/v8'tochromium_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.
T2. Character-class lookup tables
Section titled “T2. Character-class lookup tables”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:
ParseJsoncallsParseJsonValueRecursive()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 parserParseJsonValue<false>with an explicitstd::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 bufferingkeys[]/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:
ScanJsonStringnever builds a string; it returns a 12-byteJsonString{start, length, needs_conversion, internalize, has_escape}(json-parser.h:29-98; json-parser.cc:2089-2210). Materialization is deferred toMakeString(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 viastd::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 callsstring_table()->TryLookupKeyto 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();sliceof ≥13 chars is O(1) in V8 (SlicedString), so the no-escape fast path is nearly free; decode escapes only when seen; keep your ownMap<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.fromCharCodememoized 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
feedbackfor 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 —FastKeyMatchis a memcmp pluscursor_[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-stateFastIterableState(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,BuildJsonObjectbypasses the generic builder: verify each valueFitsRepresentation/field type of the feedback map’s descriptors, then allocate straight from the final map (NewJSObjectFromMap) and write in-object fields raw withSKIP_WRITE_BARRIER(json-parser.cc:804-896). - Transition walking otherwise:
JSDataObjectBuilder::TryFastTransitionToPropertyKeyusesTransitionsAccessor::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:
BuildJsonArrayscans 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 anew 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.
T7. Preallocation / handle / GC tricks
Section titled “T7. Preallocation / handle / GC tricks”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.
T8. SIMD
Section titled “T8. SIMD”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)”- Flags tables (T2) — Uint8Array(256) char-class tables for YAML’s indicator/space/scalar-safe classes: fully replicable, big win.
- Deferred, zero-copy scalars (T4) — record offsets, slice lazily, escape-free fast path: fully replicable.
- 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. - Inline int fast path + number buffering (T5) — replicable; avoids
Number()+slice for the common integer case. - Recursion with buffered key/value flat stacks (T3) — replicable; enables exact-size object/array construction.
- Native scan borrowing (T8 substitute) —
indexOffor long-run skipping (line ends, doc markers, quote/backslash search). - 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.
KEY FACTS
Section titled “KEY FACTS”- 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