A single regular expression once pushed Cloudflare's HTTP-serving CPUs to nearly 100% worldwide.
The network returned 502s for 27 minutes. The rule had passed review and CI.
The regex returned the right answers. It just needed a catastrophic amount of work to return one of them.
That is the uncomfortable lesson behind Regular Expression Denial of Service (ReDoS): for a pattern that touches untrusted input, correctness has two dimensions. It must accept the right language, and it must reject the wrong input within a bounded amount of work.
Let's break a small Node.js validator, watch why it explodes, and replace it with a production rule you can actually defend.
The bug hides in a perfectly reasonable test suite
Suppose an API accepts a comma-separated list of word-like tags. This pattern looks permissive and compact:
const tags = /^(\w+,?)*$/;
tags.test("node,security,regex"); // true
tags.test("node,,regex"); // false
The fixtures pass. Short invalid inputs fail instantly. A reviewer sees anchors, an allowed character class, and no suspicious .*.
Now time a failure that arrives late:
import { performance } from "node:perf_hooks";
const vulnerable = /^(\w+,?)*$/;
for (const n of [18, 20, 22, 24, 26]) {
const input = "a".repeat(n) + "!";
const start = performance.now();
vulnerable.test(input);
console.log(n, (performance.now() - start).toFixed(1), "ms");
}
On my Node 22.16 run, the last four measurements were roughly 4.6, 17.6, 67.8, and 265.4 ms. Exact timings depend on the CPU and runtime, but the shape matters: two extra a characters cost about four times as much.
The exclamation mark does the damage. Until that final character, the engine can believe it has a match. When ! violates the anchor, the engine tries all the other ways the repeated group could have consumed the same run of as.
One request can now occupy Node's event-loop thread for hundreds of milliseconds. A slightly longer value turns a validator into an availability incident.
Backtracking turns ambiguity into a search tree
JavaScript's V8 engine normally uses Irregexp, a backtracking engine. When a pattern offers several possible ways forward, it tries one. If a later token fails, it rewinds to the last choice and tries another.
That is useful. Backtracking helps implement features such as lookarounds and backreferences, and it is extremely fast for ordinary patterns. The dangerous case is overlapping choices inside repetition.
Our group can match the same aaaa in many equivalent partitions:
aaaa
aaa + a
aa + aa
aa + a + a
a + aaa
a + aa + a
...
The final ! makes every partition fail, so the engine must disprove them one by one. OWASP's canonical example shows the same doubling: ^(a+)+$ has 16 paths for aaaaX, but 65,536 paths for sixteen as followed by X.
This is why successful-input benchmarks are misleading. Node's own guidance warns that vulnerable expressions often match long valid strings quickly; the blow-up appears when a mismatch arrives after the engine has accumulated many unresolved choices.
Not every nested quantifier is slow on every runtime. Engines recognize and optimize some shapes. That is not a safety proof. If two repeated pieces can consume the same characters, treat the pattern as suspect and test it on the exact engine you deploy.
Remove the ambiguity instead of tuning it
The tag grammar has a simpler shape: one word, followed by zero or more comma-plus-word pairs.
const safeTags = /^\w+(?:,\w+)*$/;
safeTags.test("node,security,regex"); // true
safeTags.test("node,,regex"); // false
Each character now has one structural job. A comma can only begin the next pair; a word character cannot be reassigned across nested repetitions. The same hostile inputs stayed below the useful resolution of the small timing loop on my machine.
That rewrite suggests a general review technique:
| Suspicious shape | Question to ask | Safer direction |
|---|---|---|
(x+)+ or (x*)*
|
Can inner and outer loops repartition the same text? | Collapse to one repetition |
| `(a | aa)+` | Do alternatives share a prefix? |
.*.*TOKEN |
Can adjacent wildcards trade characters? | Use one bounded class or a delimiter-aware parser |
| Backreference on user input | Is there any worst-case guarantee? | Bound input and isolate work, or avoid the feature |
Changing greedy * to lazy *? is not a general fix. In Cloudflare's postmortem, laziness improved one matching case but did nothing for the catastrophic failure variant. It changed which path the engine tried first, not how many paths existed.
The real fix is to remove overlapping interpretations.
Put more than one guardrail around untrusted regex
A safe pattern is the first layer, not the whole design.
First, cap input before matching. If a tag field is allowed to contain 200 characters, reject 20,000 bytes before the regex sees them. A length limit does not turn exponential work into linear work, but it puts a ceiling on the blast radius and protects parsers, logs, and downstream storage too.
Second, use simpler primitives when they express the rule. For literal containment, includes() or indexOf() is linear and easier to review. For comma-separated data, splitting and validating each token may be clearer:
function isTagList(value) {
if (value.length === 0 || value.length > 200) return false;
const parts = value.split(",");
return parts.length <= 20 &&
parts.every((part) => /^\w+$/.test(part));
}
Third, add adversarial tests. A good regex test suite contains long near misses: valid prefixes with one illegal suffix, missing delimiters, repeated shared prefixes, and values at the maximum permitted length. Mozilla's engineering guidance makes the same point: test large non-matching inputs and verify that they fail quickly, because happy-path correctness will not expose backtracking risk.
Finally, run a ReDoS-aware static analyzer in CI, but treat its output as a lead. Regex complexity depends on both syntax and engine behavior; a clean scan is not a runtime guarantee.
Node makes synchronous regex a shared failure
In a threaded request-per-worker server, one pathological match can consume one worker. In a normal Node process, a synchronous RegExp.test() runs on the event-loop thread. While it searches, unrelated timers, sockets, and requests wait too.
That changes the production question from “is this validation slow?” to “how many users share this stall?”
V8 has explored an escape hatch. Since V8 8.8, an experimental non-backtracking engine can provide linear time, with an optional fallback after 50,000 backtracks. But the fallback is not universal: patterns with backreferences, lookarounds, some finite repetitions, or the u and i flags are excluded. V8 also notes that the linear engine can be orders of magnitude slower for common patterns.
So do not assume the runtime will rescue an ambiguous validator. If you truly must evaluate complex or user-supplied patterns, isolate that work in a worker or separate process with a killable time budget. A Promise.race() around synchronous RegExp.test() is not a timeout—the event loop cannot fire the timer while the regex owns the thread.
In production, watch event-loop delay, CPU saturation, input sizes, and per-validator latency. Name expensive validators in traces or metrics. A generic “request took 3 seconds” span is much less useful than “username_regex took 2.9 seconds on a 41-byte rejection.”
The code-review rule worth keeping
Cloudflare's outage was not just “one bad regex.” The rule was deployed globally, CPU safeguards had gaps, and rollback took time. Still, the initiating pattern reduced to adjacent ambiguous wildcards, and the network lost around 80% of its traffic at one point.
The small version of that failure can live in any signup form, route matcher, log parser, or dependency.
When a regex touches untrusted input, review four things:
- Can repeated parts consume the same characters?
- Does a long near miss fail quickly?
- Is input length bounded before matching?
- Can one match block shared work?
If you remember one thing, test the rejection path—not just the answer. A regex that says false after 30 seconds is not correct.
What is the nastiest production regex you have had to untangle?
Top comments (1)
This is a very nice article, and a good reminder that just passing tests is not a guarantee of safety, especially when it comes to working regexes. The cost of the failure case is just as important as the cost of the success case, and it's easy to overlook.
I really like the point you made about how correctness has two dimensions, and that a validator that takes exponential time to report an error is just as bad as one that takes exponential time to report a match. That's a good point that more people should consider. You made a very good point about testing invalid cases, and I think that's something that a lot of people who use regexes could stand to do more of.
I also really like how you pointed out that it's better to design things so that they can't be misunderstood, rather than trying to optimize them. That's a good rule of thumb for a lot of things, not just regexes.
I've had the problem of backtracking issues in my own validation code before, and it's surprising how easy it can be to overlook. This is a good reminder that regexes need to be taken seriously as part of a system, not just an afterthought. I'd love to hear your thoughts on other ways to write safe, defensive code and validation strategies.