deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

28-character input pins Node.js for 3.6 seconds via catastrophic regex backtracking

A dev.to benchmark shows a 28-character string keeping Node 22's V8 engine busy for 3.6 seconds through catastrophic backtracking, and why length caps and bounded quantifiers don't help.

28-character input pins Node.js for 3.6 seconds via catastrophic regex backtracking

A developer has published a concrete benchmark showing how a 28-character string can keep a Node.js process busy for 3.6 seconds doing nothing but regex matching. Writing on dev.to, the author timed the pattern /^(a+)+$/ on Node 22 (V8) against inputs consisting of the letter "a" repeated and followed by a single "X", which guarantees the match fails.

Doubling with every character

According to the post, rejection times grew roughly twofold with each added character: 8.6 ms at 20 characters, 34.2 ms at 22, 136.6 ms at 24, 553.7 ms at 26, 1,103.8 ms at 27, and 3,586.4 ms at 28. Since every extra character doubles the work, twelve more would multiply it by 4,096; the author estimates a 40-character input would land in the region of four hours.

Failure is the trigger

The blowup only appears when the match fails. The same pattern run against 5,000 "a" characters with no trailing "X" returns a match in 0 ms. As the post explains, the cause is ambiguity inside a nested quantifier: (a+)+ lets the engine partition a run of "a"s in every possible way, roughly 2^(n-1) splits for n characters, and a failed match forces it to exhaust every one of them before giving up.

The author points out that the pattern is not exotic. A routine email validator measured 0.4 ms on a 16-character input, 12.5 ms at 24 characters, and 200.7 ms at 28 once an exclamation mark was appended — the same structure producing the same failure mode at lengths ordinary users type every day.

Fixes that do not work

Three intuitive remedies fail, according to the measurements:

  • A length limit is not sufficient, because 28 characters is short and plenty of legitimate input is longer.
  • An upper bound makes things worse: ^(a+){1,64}$ took 4,414 ms at 26 characters, versus 553.7 ms for the unbounded version. The bounded outer loop multiplies the number of possible splits instead of capping the depth.
  • Possessive quantifiers do not exist in JavaScript; new RegExp('^a++$') throws a "Nothing to repeat" error. The post notes that Perl and PCRE support a++, .NET offers Regex.MatchTimeout, and PHP ships pcre.backtrack_limit with a default of 1,000,000, while V8 provides neither a limit nor a timeout and simply blocks the event loop.

What does work

The author's working alternatives:

  • Rewrite the repetition so there is exactly one way to match. ^a+$ rejected the failing case on a 1,000,000-character input in 3.2 ms, roughly a thousand times faster on an input 35,000 times longer.
  • Emulate an atomic group with a lookahead and a backreference: /^(?=(a+))\1$/ ran in 0 ms on the exact input that took 1,103.8 ms, while still returning correct results for "aaa", "a", "" and "aab".
  • Contain the damage: cap input length, run untrusted patterns in a worker thread with a timeout, or switch to RE2, an engine designed to complete in linear time.

Why it matters

Node.js executes JavaScript on a single event loop, so one pathological match can stall an entire server rather than a single request. And because the slowdown fires only on failed matches, test suites fed valid data will pass while production falls over on a malformed query parameter, a truncated field, or a name containing one unexpected character. The author's closing advice is to probe the failure path on purpose — a long valid prefix plus one invalid trailing character — and treat a frozen regex tester tab as a preview of what the server will do.

  • #node-js
  • #regex
  • #javascript
  • #performance
  • #denial-of-service

Related posts