Conversation
V8 13.8 (Node.js 25) added a fast path to JSON.stringify that outruns the string concatenation we generate. It only applies to plain fast-mode objects with no accessors, no toJSON and no Date values, so a user's own objects rarely qualify — measured on Node 26, JSON.stringify drops from 6.7M ops/cpu-s on a plain object to 1.4M with a getter, 1.7M on a null-prototype object and 2.2M on a class with toJSON. But the objects we could build from them always qualify. So on a supporting V8, arrays of two or more items are now serialized by projecting each item into a new object holding exactly the schema's properties, already coerced, and handing the resulting array to JSON.stringify. Schema filtering and coercion are unchanged, and the concatenation path takes over for anything the projection does not model: anyOf/oneOf/allOf/if, $ref, patternProperties, additionalProperties, const, default, tuples, unsafe strings, integer-like and __proto__ keys, objects nested more than two levels deep, and BigInt values, which are detected at runtime and abandon the projection. Paired CPU-time measurements on Node 26, median of 11 alternating rounds: array of 4 objects 1.51x array of 1k numbers 2.03x array of 32 objects 1.65x array of 1k strings 2.37x array of 1k objects 1.80x array of 1k nested 1.08x array of 20k objects 1.83x single object 1.01x Against JSON.stringify itself, the 20k-object array goes from 0.42x to 0.79x and the 1k-object array from 0.53x to 0.89x; the rest of the remaining gap is the schema filtering JSON.stringify does not do. Node 24 and older generate byte-identical code to before. A differential test over 189090 schema/value/length combinations confirms the two paths agree byte-for-byte, including which errors are thrown. The detection reads process.versions.v8 and can be overridden with the new `arrayProjection` option, which also lets the tests exercise both paths on every runtime.
Tony133
approved these changes
Sep 22, 2026
Tony133
left a comment
Member
There was a problem hiding this comment.
LGTM 🚀
-
Keep
arrayProjectionpublic: it's a real escape hatch, and relaxing thec8 --100gate would cost more than one boolean. Maybe note in the README that inmode: 'standalone'the V8 check runs at build time and is baked into the output. -
Agreed on leaving single objects out; the array-length threshold clearly doesn't carry over, so that needs its own heuristic in a follow-up.
Two quick questions:
- Is gating on
largeArrayMechanism === 'default'deliberate? With'json-stringify'the arrays belowlargeArraySizeare still concatenated and could be projected too. - Should the recursive-schema comment in
buildObjectProjectionFunctionsit next to the depth check rather than theadditionalPropertiesone?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to the measurements in #782. V8 13.8 (Node.js 25) added a fast path to
JSON.stringifythat outruns the string concatenation we generate, and our own throughput is flat across V8 versions — on Node 26 a 20k-object array serializes at 0.42x the speed of plainJSON.stringify, and a 1k-object array at 0.53x.That fast path only applies to plain fast-mode objects: no accessors, no
toJSON, noDatevalues. Measured on Node 26,JSON.stringifydrops from 6.7M ops/cpu-s on a plain object to 1.4M with a getter, 1.7M on a null-prototype object and 2.2M on a class withtoJSON, so the objects users actually hand us often miss it. But the objects we could build from them always qualify.So on a supporting V8, arrays of two or more items are now serialized by projecting each item into a new object holding exactly the schema's properties, already coerced, and handing the resulting array to
JSON.stringify. Schema filtering and coercion are unchanged and the output is byte-identical. The concatenation path takes over for anything the projection does not model:anyOf/oneOf/allOf/if,$ref,patternProperties, truthyadditionalProperties,const,default, tupleitems, theunsafestring format, integer-like and__proto__property names, and objects nested more than two levels deep.BigIntvalues cannot be modelled statically, so they are detected at runtime and abandon the projection for that call.Paired CPU-time A/B against
mainon Node 26, median of 11 alternating rounds, each case asserting identical output before timing:anyOf, declinedAgainst
JSON.stringifyitself the 20k-object array goes from 0.42x to 0.79x and the 1k-object array from 0.53x to 0.89x; the rest of that gap is the schema filteringJSON.stringifydoes not do.The two thresholds came out of measurement. Arrays shorter than two items are faster to concatenate, and projecting objects more than two levels deep allocates more than it saves — at depth 4 the nested-array case regressed to 0.94x, which is why the limit is 2.
On Node 24 and older the generated code is byte-identical to
main, verified by comparingmode: 'debug'output, and the A/B puts every case within noise of 1.00x there.A differential harness builds every schema shape under both paths and compares output including which errors are thrown: 189,090 schema/value/length combinations, zero mismatches, on Node 24, 25 and 26.
Two things worth deciding in review. The behaviour is detected from
process.versions.v8and exposed as a newarrayProjectionoption — that is partly a real escape hatch and partly becausec8 --100cannot pass on a Node matrix when a codegen branch only executes on newer V8, since whichever runtime CI uses the other path is dead. If we would rather not add public API for it, the alternative is relaxing the coverage gate. And single objects are deliberately untouched: they still lose (0.60x on a 15-property object, 0.55x nested), but a side experiment put the same projection at 1.66x for a wide or nested object and 0.78x for a small one with optional properties, so it needs its own shape-based heuristic rather than the array length threshold used here.Benchmarked on Node 24.18.0 (V8 13.6), 25.2.1 (V8 14.1) and 26.5.1 (V8 14.6), pinned with
taskset, measuring CPU time rather than wall time.