Repository navigation
perf: make safeStringify 4-6× faster for plain data - #56
Merged
thetutlage merged 2 commits intoOct 12, 2026
Merged
Conversation
safeStringify always gave a replacer function to safe-stable-stringify.
That forces its slow path, which calls a function and runs
stack.indexOf for every value. It was 4 to 7 times slower than
JSON.stringify for plain data.
Try JSON.stringify first. Fall back to safe-stable-stringify only
when it throws a TypeError, which V8 raises for bigints and circular
references. A user replacer is wrapped so it converts bigints in the
native pass, so only circular values run the replacer twice.
The output does not change, with two exceptions. Boxed primitives
now serialize as their value (new String('ab') gives "ab", not
{"0":"a","1":"b"}). A negative space no longer throws a RangeError.
On Node.js 25+, a V8 bug in the JSON.stringify fast path makes it take
5 to 20 ms to reject a small circular plain object, so circular values
are slower there until V8 fixes it.
Member
|
Looks good 👍🏽 |
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.
Hey! 👋
@adonisjs/http-serverusessafeStringifyas its defaultserializeJSON, so every JSON response goes through it. It always gives a replacer function tosafe-stable-stringify, which forces its slow path: one function call and onestack.indexOfper value. On Node.js 26, it is 4 to 7 times slower thanJSON.stringifyfor plain data.safeStringifynow triesJSON.stringifyfirst. It falls back to the currentsafe-stable-stringifypath only whenJSON.stringifythrows aTypeError, which V8 raises for bigints and circular references. Other errors, such as an error fromtoJSONor from the replacer, are rethrown without a retry.When you give a replacer, it is wrapped so bigints are converted in the native pass. The replacer runs once per value, also with bigints. Only circular references make the replacer run a second time, during the fallback. Tests cover both cases.
Behavior changes
On the native path, these outputs change:
new String('ab')gives"ab"instead of{"0":"a","1":"b"}, which matchesJSON.stringify. I consider this a fix.spaceno longer throws aRangeErrorfrom' '.repeat(-1). It gives compact output, likeJSON.stringify.Not changed: when a value also has a bigint (without a replacer) or a circular reference, the fallback runs, and boxed primitives keep the old
{"0":"a","1":"b"}output.I compared the old and new outputs on 48 values: nested bigints, circular objects and arrays, lone surrogates,
toJSON,Date,Map/Set,Buffer,Error, objects without a prototype, array holes, symbol keys, getters, shared references,spacevalues, and replacers. The only differences are the 4 listed above (3 boxed primitive types and the negativespace).Known V8 bug: circular values on Node.js 25+
V8 14.1 and later (Node.js 25+) has a performance bug in
JSON.stringify, reported as https://issues.chromium.org/issues/572287937. To reject a small circular plain object, it takes 5 to 20 ms and allocates about 10 MB. The thrown error is correct. Only the cost is wrong. Repro:Measurements:
--no-json-stringify-fast-path: 11 µs. The cost comes from the V8 fast path.The fast path checks for cycles only once every 200,000 iterations (source). On a cyclic value, it serializes the cycle until that check runs, then the slow path starts again and throws.
Until V8 fixes it, values with circular references are much slower with this PR on Node.js 25+. A replacer or a
spacevalue disables the fast path and prevents this cost, but it also removes most of the gain. I kept the fast path because circular values are not normal in response bodies. When V8 fixes the bug, this cost goes away with no change here. A code comment documents it.Benchmark
Node.js v26.10.0, median of 15 runs. Each item has 7 fields, including a nested object and an array.
{ a, self }circular (fallback)In
@adonisjs/http-server9.3.1, a route that returns 20 objects went from 56 to 15 µs per request, and from 9,600 to 15,000 req/s (+55%) with autocannon.npm test(211 tests),npm run lint, andnpm run typecheckpass. On the old implementation, 3 of the new tests fail: boxed primitives, negativespace, and the second replacer pass on circular values. The other new tests pass on both versions and protect the current behavior.