Skip to content

perf: escape strings with JSON.stringify instead of a per-character loop - #217

Merged
elliott-with-the-longest-name-on-github merged 2 commits into
sveltejs:mainfrom
http-samc:stringify-string-native
Oct 7, 2026
Merged

elliott-with-the-longest-name-on-github merged 2 commits into
sveltejs:mainfrom
http-samc:stringify-string-native

Conversation

@http-samc

@http-samc http-samc commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

stringify_string has a fast path for strings with nothing to escape, but anything that needs even one escape falls into a JS loop over every character, appending a slice per escaped character. Content with many quotes (JSON embedded in a string, source code, log lines) hits that path hard: about 12 ms/MB locally.

JSON.stringify already produces every escape stringify_string emits — ", \, control characters as \n/\t/\u001f etc., and lowercase \udXXX for unpaired surrogates — except for < and the U+2028/U+2029 separators, which it leaves raw. This change uses it and fixes those three up in a single regex pass over a small lookup table. Output is byte-identical to the previous implementation (verified by comparing old and new over every BMP code unit in several contexts, all surrogate pairings, and random strings over the escape alphabet: 0 mismatches), so the existing escape, XSS and surrogate tests pass unchanged. Two tests are added: one for the three post-pass characters mixed with JSON escapes and an unpaired surrogate, and one asserting that characters JSON.stringify leaves raw (/, DEL, non-ASCII, >) stay raw on the escaping path.

On JSON-shaped strings:

size before after
0.3 MB 3.6 ms 1.4 ms
7.2 MB 83.9 ms 37.2 ms
34.6 MB 514 ms 176 ms

Net −38 lines (drops get_escaped_char and the loop).

Strings that contain anything to escape walked every character in JS,
appending a slice per escaped character. Content with many quotes (JSON
embedded in a string, source code) hit that path hard: ~12 ms/MB.

JSON.stringify already produces every escape stringify_string emits —
quotes, backslashes, control characters, and \udXXX for unpaired
surrogates — except for `<` and the U+2028/U+2029 separators, which it
leaves raw. Use it and fix those three up in one regex pass. Output is
byte-identical to the previous implementation; the existing escape and
surrogate tests pass unchanged. 2-3x faster on multi-MB JSON-shaped
strings.
@changeset-bot

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 49a761f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
devalue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@http-samc http-samc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewed against main. I found nothing wrong with this change: the output is provably identical to the previous implementation, the perf numbers reproduce, and the diff is a clean simplification. Comments inline are a style nit and a small test-coverage suggestion.

Verified locally

  • Equivalence: old vs new stringify_string compared over 418,318 inputs (every BMP code unit in three contexts, all surrogate pairings, an astral sweep, hand-picked specials including the empty string, and 200k random strings over "/\/</controls/U+2028/U+2029/lone and paired surrogates/é///U+007F): 0 mismatches, all outputs eval back to the input.
  • pnpm test: 1307/1307 pass. pnpm exec prettier --check src .changeset: clean.
  • Perf, JSON-shaped strings on Node 24 (old → new): 0.3 MB 8.2 → 1.9 ms; 7.2 MB 345 → 54 ms; 34.6 MB 1731 → 243 ms. A 7 MB plain string with a single trailing < went 94 → 47 ms, and a 7 MB string with a single lone surrogate 110 → 70 ms. The fast path (emoji, nothing to escape) is unchanged (28 vs 30 ms, noise). So the table in the description is, if anything, conservative on this machine.
  • Changeset present (devalue patch), which matches how the previous perf: entries in CHANGELOG.md were released.

Nits

  • Title/changeset prefix: this is a performance refactor with no behavior change, and the repo's recent history uses perf: for exactly this kind of commit (perf: cheaper stringify for plain data and strings (#190)). fix: suggests a correctness bug was present. Consider perf: for both the PR title and the changeset line so the changelog groups it with its siblings.
  • The comment block above json_leaves_raw is accurate (JSON.stringify's handling of unpaired surrogates has been well-formed since ES2019). The parenthetical about older engines rejecting U+2028/U+2029 in string literals is historical since ES2019 as well; still a fine reason to keep escaping them for compatibility, just noting it's not a current-engine constraint.

Comment thread src/utils.js
Comment thread src/utils.js Outdated
Comment thread src/utils.test.js
@http-samc http-samc changed the title fix: escape strings with JSON.stringify instead of a per-character loop perf: escape strings with JSON.stringify instead of a per-character loop Oct 6, 2026
@elliott-with-the-longest-name-on-github

Copy link
Copy Markdown
Contributor

Thank you!

@elliott-with-the-longest-name-on-github
elliott-with-the-longest-name-on-github merged commit 5cd712a into sveltejs:main Oct 7, 2026
5 checks passed
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