Skip to content

perf: give plain string arrays their own copy-then-JSON.stringify cutoff - #905

Open
Tony133 wants to merge 1 commit into
mainfrom
perf/string-array-fast-path-cutoff
Open

Tony133 wants to merge 1 commit into
mainfrom
perf/string-array-fast-path-cutoff

Conversation

@Tony133

@Tony133 Tony133 commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Proposal:

Follow-up to #900 and #903.

Problem

#900 gave plain number and integer arrays their own measured cutoffs for the copy-then-JSON.stringify path (128 / 512 on Node 22+), but plain string arrays are still delegated only at or above largeArraySize (default 20000). Below that, every element goes through the generated loop and asString, which escapes in JS and is far slower than the native serializer. In practice a tags: string[] with 10-100 elements, which is the common case, never hits the fast path.

With #903 in place the copy actually reaches V8's fast path on Node 25+, which makes the gap even wider.

Measurement

Crossover (array length from which the copy beats the generated loop), median of 5 interleaved rounds, output consumed with Buffer.byteLength, main after #903. Strings of 8, 40 and 120 chars, with and without characters to escape.

Node.js 8 chars 40 chars 120 chars 8 chars + escapes
26 (V8 14.6) 2 1 1 1
24 (V8 13.6) 2 1 1 1
22 (V8 12.4) 2 1 1 1
20 (V8 11.3) 32 32 128 1

On Node 22+ the copy wins from 2 elements everywhere, and is 2-6x faster from 8 elements on. On Node 20 the native serializer is slower on long strings, so the safe cutoff is 128.

Change

Add string to PRIMITIVE_FAST_PATH_MIN_LENGTH with the same two-tier structure as number/integer: 8 on Node 22+, 128 on Node 20. largeArraySize now only drives largeArrayMechanism: 'json-stringify', which was its original purpose. Output is byte-identical; the miss/resume semantics of asPlainStringArray are unchanged. README and the largeArraySize JSDoc updated accordingly.

Below the cutoff nothing changes. largeArrayMechanism: 'json-stringify' is unaffected.

Benchmark (before = main with #903, after = this PR)

Median of 5 interleaved rounds, output consumed with Buffer.byteLength. native is plain JSON.stringify; before and after are fast-json-stringify on main and on this branch. tags[n] is { id, name, tags: string[n] }, the typical Fastify response shape.

Node 26.12.0 (V8 14.6)

case native before after after vs before after vs native
string[4] (below cutoff) 16.41M 6.48M 6.44M -1% -61%
string[8] 12.26M 3.61M 9.82M +172% -20%
string[32] 3.50M 959.7k 2.52M +163% -28%
string[100] 1.37M 310.8k 1.05M +239% -23%
string[1000] 144.7k 30.4k 109.7k +261% -24%
string[1000] 40-char 36.5k 12.7k 34.3k +171% -6%
object with tags[10] 8.16M 2.46M 5.02M +104% -38%
object with tags[50] 2.23M 573.9k 1.64M +185% -27%
string[1000] json-stringify mode 147.0k 144.9k 148.3k +2% +1%

Node 24.19.0 (V8 13.6)

case native before after after vs before after vs native
string[4] (below cutoff) 9.50M 6.86M 6.74M -2% -29%
string[8] 5.96M 3.85M 5.73M +49% -4%
string[32] 2.04M 1.01M 1.87M +85% -9%
string[100] 740.2k 335.7k 679.5k +102% -8%
string[1000] 70.0k 32.8k 66.0k +101% -6%
string[1000] 40-char 32.6k 13.1k 31.1k +138% -5%
object with tags[10] 3.85M 2.62M 3.63M +38% -6%
object with tags[50] 1.29M 624.7k 1.14M +82% -12%
string[1000] json-stringify mode 74.5k 70.4k 72.6k +3% -3%

Node 22.23.1 (V8 12.4)

case native before after after vs before after vs native
string[4] (below cutoff) 10.65M 6.91M 6.89M -0% -35%
string[8] 7.01M 3.95M 6.37M +61% -9%
string[32] 2.35M 1.00M 2.06M +106% -12%
string[100] 828.5k 329.3k 729.6k +122% -12%
string[1000] 79.6k 31.8k 69.1k +117% -13%
string[1000] 40-char 35.0k 12.6k 33.1k +164% -5%
object with tags[10] 4.38M 2.58M 3.78M +46% -14%
object with tags[50] 1.43M 631.7k 1.18M +87% -18%
string[1000] json-stringify mode 82.7k 82.1k 82.6k +1% -0%

Node 20.20.2 (V8 11.3) — cutoff 128, so arrays below it keep the loop:

case native before after after vs before after vs native
string[4] 4.94M 6.52M 6.54M +0% +32%
string[8] 3.80M 3.50M 3.50M +0% -8%
string[32] 1.43M 943.7k 942.4k -0% -34%
string[100] 605.2k 314.8k 314.2k -0% -48%
string[1000] 71.7k 30.0k 40.3k +34% -44%
string[1000] 40-char 28.4k 11.5k 21.8k +89% -23%
object with tags[10] 2.37M 2.37M 2.36M -1% -0%
object with tags[50] 969.5k 585.9k 601.6k +3% -38%
string[1000] json-stringify mode 68.8k 71.7k 71.6k -0% +4%

Arrays below the cutoff and the json-stringify mechanism are unchanged on every runtime (within ±3%). Above the cutoff, string arrays go from 2-5x slower than native to within 5-30%.

Tests

test/string-array-cutoff.test.js: the generated code uses the string cutoff and ignores largeArraySize; output is byte-identical to JSON.stringify around the cutoff (−1, 0, +1, ×10); nested string arrays use the same cutoff; escapes, non-strings, null, Date and RegExp elements above the cutoff keep the per-element coercion semantics; largeArraySize still drives the json-stringify mechanism. Full suite green with c8 --100 on Node 22, 24 and 26.

Semver

The largeArraySize option no longer affects plain string arrays under the default mechanism. Output is unchanged, but the documented behaviour of an option changes, so this is probably semver-minor.

@Tony133
Tony133 marked this pull request as ready for review October 22, 2026 08:35
@Tony133
Tony133 requested review from gurgunday and mcollina October 22, 2026 08:35

@gurgunday gurgunday left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@gurgunday

Copy link
Copy Markdown
Member

I'd wait for another review

This branch has not been deployed

No deployments
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