Skip to content

perf: make safeStringify 4-6× faster for plain data - #56

Merged
thetutlage merged 2 commits into
poppinss:7.xfrom
RomainForks:perf/safe-stringify-native-fast-path
Oct 12, 2026
Merged

thetutlage merged 2 commits into
poppinss:7.xfrom
RomainForks:perf/safe-stringify-native-fast-path

Conversation

@RomainLanz

@RomainLanz RomainLanz commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Hey! 👋

@adonisjs/http-server uses safeStringify as its default serializeJSON, so every JSON response goes through it. It always gives a replacer function to safe-stable-stringify, which forces its slow path: one function call and one stack.indexOf per value. On Node.js 26, it is 4 to 7 times slower than JSON.stringify for plain data.

safeStringify now tries JSON.stringify first. It falls back to the current safe-stable-stringify path only when JSON.stringify throws a TypeError, which V8 raises for bigints and circular references. Other errors, such as an error from toJSON or 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:

  • Boxed primitives serialize as their value. new String('ab') gives "ab" instead of {"0":"a","1":"b"}, which matches JSON.stringify. I consider this a fix.
  • A negative space no longer throws a RangeError from ' '.repeat(-1). It gives compact output, like JSON.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, space values, and replacers. The only differences are the 4 listed above (3 boxed primitive types and the negative space).

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:

const v = { a: 1 }
v.self = v
try { JSON.stringify(v) } catch {}

Measurements:

  • Node.js 22 and 24: the same throw costs about 8 µs.
  • Node.js 26 with --no-json-stringify-fast-path: 11 µs. The cost comes from the V8 fast path.
  • A circular class instance does not use the fast path. It costs 5 µs on Node.js 26.
  • Chrome 155 still has the bug: 29.8 ms, and 0.6 ms with the fast path disabled.

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 space value 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.

Case Size Before After Speedup
20 objects 3.1 KB 29.6 µs 4.7 µs 6.3×
500 objects 80.5 KB 887.7 µs 221.1 µs 4.0×
20 objects + replacer 3.1 KB 35.8 µs 23.0 µs 1.6×
20 objects + bigint (fallback) 3.2 KB 37.9 µs 40.1 µs 0.95×
20 objects + circular (fallback) 3.2 KB 34.0 µs 100.6 µs 0.34×
{ a, self } circular (fallback) 0 KB 0.3 µs 12,471 µs ~0.00002×

In @adonisjs/http-server 9.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, and npm run typecheck pass. On the old implementation, 3 of the new tests fail: boxed primitives, negative space, and the second replacer pass on circular values. The other new tests pass on both versions and protect the current behavior.

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.
@thetutlage
thetutlage merged commit 3a5f453 into poppinss:7.x Oct 12, 2026
6 checks passed
@thetutlage

Copy link
Copy Markdown
Member

Looks good 👍🏽

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants