Skip to content

perf: skip the decoders in utils.decode when there is no percent sign - #583

Open
zhangj23 wants to merge 1 commit into
ljharb:mainfrom
zhangj23:perf/decode-skip-when-no-percent
Open

perf: skip the decoders in utils.decode when there is no percent sign#583
zhangj23 wants to merge 1 commit into
ljharb:mainfrom
zhangj23:perf/decode-skip-when-no-percent

Conversation

@zhangj23

Copy link
Copy Markdown

utils.decode runs once per key and once per value for every parsed query string, so it is the hottest per-token function in the library. Today it always builds str.replace(/\+/g, ' ') and then calls decodeURIComponent (or unescape for iso-8859-1), even when there is nothing to decode.

A string with no % has no escape to expand, in either charset. unescape has nothing to match, and decodeURIComponent is the identity on such a string and cannot throw. So only + to space remains, and often not even that.

I did not want to take that on faith, so I checked it: across 23,720 strings spanning the codepoint range plus lone surrogates, surrogate pairs, the replacement character, a 10,000-character string and the reserved delimiters, every %-free input is byte-identical under both decodeURIComponent and unescape, with no throws.

Numbers

Node v24.18.0, min and median of 31 ABBA-interleaved rounds against a separately built copy of main, with an A/A control on a byte-identical third copy:

shape min median A/A control (median)
typical query string 1.472x 1.505x 0.960x
urlencoded form body 1.273x 1.269x 0.978x
nested bracket notation 1.262x 1.252x 1.005x
40 plain pairs 1.439x 1.422x 0.986x
400 pairs 1.380x 1.460x 0.984x
percent-heavy 1.063x 1.060x 0.993x

The percent-heavy row is the honest floor. When most tokens really do contain % the fast path cannot fire, and all that is left is one extra indexOf per token, which is why it is 1.06x rather than 1.00x. I could not construct an input where it is slower than the control.

Trivial input does not regress: an empty query string is 1.003x, and a single a=1 pair is already 1.32x.

For scale, I measured an empirical ceiling by reducing decode to return str, removing the + replace and the decoder entirely. That is about 1.64x on a realistic mixed corpus, so this change captures roughly 80 percent of what is available without altering behaviour.

Why the shapes above

I deliberately built the corpus around what express and body-parser actually hand this function, rather than a best case. Browsers percent-escape aggressively, so a realistic corpus has to include percent-heavy tokens, and the two rows most representative of real traffic are the form body at 1.27x and the typical query string at 1.47x.

Behaviour

The change adds no gate on user-supplied objects, no cache and no early exit from a loop. It performs the same + replacement when a + is present and skips only the decoder call that provably cannot change the string.

The test suite passes with 1045 assertions and # ok, matching main.

Worth noting explicitly: the fast path returns the input string unchanged when there is neither % nor +. That is the same value decodeURIComponent would have returned, and since strings are immutable there is no aliasing concern, but I mention it because it means the common case now returns the identical string object rather than a fresh one.

`decode` runs once per key and once per value on every parsed query string, so it is
the hottest per-token function in the library. It always builds
`str.replace(/\+/g, ' ')` and then calls `decodeURIComponent` (or `unescape` for
iso-8859-1), even when the input contains nothing to decode.

A string with no `%` has no escape to expand, in either charset: `unescape` has
nothing to match, and `decodeURIComponent` is the identity on such a string and
cannot throw. So only `+` to space remains, and often not even that.

I checked that premise rather than assuming it: over 23,720 strings, spanning the
codepoint range plus lone surrogates, surrogate pairs, the replacement character, a
10,000-character string and the reserved delimiters, every `%`-free input is
byte-identical under both `decodeURIComponent` and `unescape`, with no throws.

Measured on node v24.18.0, min and median of 31 ABBA-interleaved rounds against a
separately built copy of `main`, with an A/A control on a byte-identical third copy
(A/A stayed within 0.975x to 1.020x):

  shape                          min      median
  typical query string         1.451x     1.404x
  urlencoded form body         1.259x     1.304x
  nested bracket notation      1.263x     1.210x
  40 plain pairs               1.389x     1.389x
  400 pairs                    1.357x     1.373x
  percent-heavy                1.058x     1.051x

The percent-heavy row is the honest floor: when most tokens really do contain `%`,
the fast path cannot fire and all that is left is one extra `indexOf` per token,
which is why it is 1.05x rather than 1.00x.

For scale, an empirical ceiling measured by reducing `decode` to `return str`
(removing the `+` replace and the decoder entirely) is about 1.64x on a realistic
mixed corpus, so this captures roughly 80 percent of what is available without
changing behaviour.

Behaviour is unchanged. The suite passes with 1045 assertions and `# ok`, matching
`main`.
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.

1 participant