The regex that stops your server, measured char by char

Sanjeev SharmaSanjeev Sharma
13 min read

Advertisement

A user pastes thirty characters into a form. Your process stops answering anything — not that request, every request — for seven seconds. No error, no crash, nothing in the logs except a gap.

This is that failure, measured character by character, on a pattern of the kind that sits in real validation code.

The short answer

A regex with nested, ambiguous quantifiers — (\w+\s?)*, (a+)+, (x|xy)* — can take time that doubles with every two characters of input. Measured here: 20 characters took 6.6 ms, 30 characters took 6,809 ms. The same language matched with an unambiguous pattern took 0.0015 ms at every length. The fix is the pattern, not a faster machine.

What the curve actually looks like

Two patterns, same input, one process. The vulnerable one is /^(\w+\s?)*$/ — words separated by optional spaces, which is what a "letters and spaces only" validator looks like when it is written quickly. The safe one is /^\w+(\s\w+)*\s?$/, which matches the same strings with one path to each match.

Input is 'a'.repeat(n) + '!'. The trailing ! is the point: it can never match, so the engine has to exhaust every way of splitting the a-run before it can say no.

Time to reject one string, by input lengthmilliseconds for a single .test() call
  • 18 chars · vulnerable1.693
  • 20 chars · vulnerable6.643
  • 22 chars · vulnerable26.332
  • 24 chars · vulnerable104.024
  • 26 chars · vulnerable419.773
  • 28 chars · vulnerable1,680
  • 30 chars · vulnerable6,809
  • 30 chars · rewritten0.0015
  • 24 chars · vulnerable — past a 100 ms budget already
  • 30 chars · vulnerable — one request, 6.8 seconds of blocked event loop
  • 30 chars · rewritten — same input, same answer

Every two characters multiplies the work by about four. That is not a slow regex; it is an exponential one, and the difference matters because exponential means the input that kills you is barely longer than the input that was fine.

Input lengthVulnerable (\w+\s?)*Rewritten \w+(\s\w+)*Ratio
18 chars1.7 ms0.0004 ms4,000×
22 chars26.3 ms0.0015 ms17,000×
26 chars419.8 ms0.0015 ms280,000×
30 chars6,809 ms0.0015 ms4,500,000×

The classic textbook pattern /^(a+)+$/ measured the same shape: 7.8 ms at 18 characters, 7,111 ms at 30. Both are the same bug wearing different clothes.

Why does adding two characters multiply the work by four?

Because the engine is trying every way to divide the input between the inner and outer quantifier, and the number of divisions grows exponentially.

Backtracking on (a+)+ against aaaa!
1 / 5

The rule of thumb that falls out of this: a quantifier inside a quantifier is only dangerous when the two can match the same character. If a separator forces the split, the ambiguity is gone.

(a+)+ — every node branches again4 characters → 8 attempts · 30 characters → 5.4 × 10⁸\w+(\s\w+)* — one pathany length → one failure, 0.0015 ms

Both patterns match the same strings. Only one of them has more than one way to do it, and that is the whole difference between 0.0015 ms and 6.8 seconds.

Which nested quantifiers are actually dangerous?

Not all of them, and this is where most advice is too blunt. The same benchmark ran /^([a-z0-9]+-)+[a-z0-9]+$/ — a slug validator, nested quantifier, looks identical in shape — against inputs up to 88 characters. It stayed at 0.001 ms.

The difference is ambiguity. In the slug pattern, - can only be matched by the separator, so there is exactly one way to split abc-def-ghi. In (\w+\s?)* the space is optional, so \w+ can swallow characters the outer loop could also have taken.

PatternNested?Ambiguous?Measured at 30 chars
^(a+)+$yesyes — a matchable by both7,111 ms
^(\w+\s?)*$yesyes — optional separator6,809 ms
^([a-z0-9]+-)+[a-z0-9]+$yesno — - forces the split0.001 ms
^\w+(\s\w+)*\s?$yesno — separator is required0.0015 ms

So the test is not "does it nest". It is: can the inner and outer parts match the same character? If yes, there is more than one path, and the engine will try all of them before giving up.

What to do about it

OptionCostCatchesEffortPick it when
Rewrite the patterndefault0the actual bugminutesYou own the pattern and can express the separator explicitly
Cap input length before matching~0everything past the capone lineAlways, as a second layer — a 64-character limit makes most of these unreachable
Swap in RE2native dep, no backtracking featuresall catastrophic patternsa dependencyPatterns come from users or config and cannot be audited
Run matching in a worker with a timeoutthread + message overheadthe symptom, not the causea harnessUntrusted patterns you must keep supporting
Hope0nothingnoneNever. The input length that kills you is only two characters past one that was fine
The order matters: rewriting is free and permanent, capping input costs one line, and the engine swap is for the case where you do not control the pattern.

The rewrite rule is mechanical: make the separator mandatory inside the repeat and lift the optional part out. (\w+\s?)* becomes \w+(\s\w+)*\s?. Same language, one path. Both the MDN reference on quantifiers and the regular expression guide spell out greedy versus lazy matching, which is the mechanism underneath.

If the patterns are not yours — a search box that accepts a regex, rules loaded from a database — no rewrite can save you, and the answer is an engine that does not backtrack at all. RE2 guarantees linear time by refusing the features that make backtracking necessary, and node-re2 exposes it with a RegExp-shaped API.

When does your pattern cross your timeout?

Input length that blows the budget

formula: unknown variable "Math" in "20 + 2 * Math.ceil(Math.log(budget / base) / Math.log(4))"

20 + 2 × log₄(200 ÷ 7) — the measured curve quadruples every 2 chars

Time at your maximum accepted length

formula: unknown variable "Math" in "base * Math.pow(4, (maxlen - 20) / 2)"

7 ms × 4^((64 − 20) ÷ 2)

Safe input cap for this budget

formula: unknown variable "Math" in "20 + 2 * Math.floor(Math.log(budget / base) / Math.log(4))"

the length where a single match still fits inside the budget

Only meaningful for a pattern you have already measured as exponential. Put your own number in the second slider by timing one .test() at 20 characters — the script in this repository prints it.

How to find these before a user does

Three passes, cheapest first.

The first is grep: look for )*, )+, ){ immediately after a group that ends in a quantifier — (\w+)+, ([a-z]*)*, (\d+|\d+\w)*. That catches most of them by shape.

The second is the measurement in this post. Take each suspicious pattern, run it against 'a'.repeat(n) + '!' for n from 14 to 30, and watch the times. Linear means safe; quadrupling means exponential. The script is tools/bench/regex-backtracking.mjs and it takes about a second per pattern.

The third is a hard input cap at the edge — body size limits, field length validation, a maxLength on the input — because it turns an exponential problem into a bounded one regardless of what the pattern does. Validation that runs before the expensive check is the same idea as validating pagination parameters before they reach the database.

Can you spot the dangerous one?

3 questions — answers explained as you go.

  1. 1. Which of these can take exponential time?

  2. 2. Your pattern takes 7 ms at 20 characters. What does it take at 28?

  3. 3. Users supply the regex themselves. What is the fix?

Frequently Asked Questions

What is catastrophic backtracking?

A regular expression where the engine has more than one way to match the same input, so a failing match forces it to try every combination. The number of combinations grows exponentially with input length — in this article, four times the work for every two extra characters.

Does a nested quantifier always mean a ReDoS?

No. A nested quantifier is only dangerous when the inner and outer parts can match the same character. A slug pattern like ^([a-z0-9]+-)+[a-z0-9]+$ nests but stays linear, because the hyphen forces exactly one split. It measured 0.001 ms at 88 characters.

Can I just add a timeout to the regex?

JavaScript has no per-regex timeout. The match runs on the same thread as everything else, so the process is unresponsive until it finishes. A timeout only helps if the match runs in a worker thread you can terminate, which costs a thread and a message round trip per call.

How do I test my own patterns for this?

Time a single .test() against strings of increasing length that cannot match — an alphabetic run plus one invalid character is the standard shape. If the time roughly quadruples every two characters, the pattern is exponential. The script in this repository does it in about a second per pattern.

Is this a Node problem specifically?

No. It is how backtracking engines work, so the same patterns behave the same way in browsers, Python, Java and PHP. What differs is the blast radius: in a single-threaded runtime, one bad match stops every concurrent request on that process rather than one thread out of many.

The part worth remembering

The input that takes seven seconds is thirty characters long. There is no gradual degradation to alert on, no slow query to spot, and the request that does it looks exactly like the one before it.

So treat this as a code-review rule rather than a monitoring problem: a quantifier inside a quantifier gets read out loud, and if the two can match the same character, it gets rewritten before it merges. When one does slip through, the symptom is an event loop that stops rather than an error — which is the same signature as the blocking work covered in Node performance profiling, and it is worth knowing both shapes.

For the surrounding practice: error handling that survives async is what tells you a request died rather than vanished, async logging keeps the log line itself off the hot path, and the backend performance checklist lists input caps alongside the other cheap defences. The measurement method here — interleave, take the best round, print the spread — is the same one used in Node 22 vs 24 vs 26, and the context cost it borrows from is in what AsyncLocalStorage costs.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading