I maintain CodeSwap, a developer-tools site with browser-based utilities and technical guides. While reviewing its regular-expression tools, I wanted a repeatable answer to a deceptively simple question:
How can I tell whether a regex is merely slow or capable of pinning a CPU with a tiny hostile input?
The answer was not another list of patterns labeled “safe” or “unsafe.” It was a small audit process: identify ambiguous repetition, construct a failing input, measure growth at several lengths, rewrite the pattern, and measure the same input again.
This article documents that process. The exact timings come from a Node.js 22 test run used for the original CodeSwap guide. Hardware and engine versions change absolute numbers, but the growth curve is the useful signal.
The failure mode: many valid paths, followed by one failure
Most JavaScript regular expressions use backtracking. The engine makes a greedy choice, and if something later fails, it revisits earlier choices.
That is normally harmless. The dangerous case appears when the same characters can be divided or matched in many different ways.
Consider this pattern:
/^(a+)+$/
The inner a+ can consume one or many a characters. The outer + can repeat that group one or many times. For an all-a string, the first path succeeds quickly. Add one final character that cannot match, however, and the engine must explore a rapidly growing number of partitions before returning false.
OWASP uses this same nested-quantifier shape when explaining Regular Expression Denial of Service, or ReDoS. Its warning signs include repetition inside a repeated group and overlapping alternatives inside repetition.
A measurement that exposes the curve
The useful test input is not a random long string. It is a string that lets the risky portion match repeatedly and then fails at the end:
const vulnerable = /^(a+)+$/;
function measure(length) {
const input = "a".repeat(length) + "!";
const started = performance.now();
const matched = vulnerable.test(input);
return {
length,
matched,
milliseconds: performance.now() - started,
};
}
for (const length of [20, 22, 24, 26, 28, 30]) {
console.log(measure(length));
}
The recorded run produced this curve:
Repeated a characters |
Time before rejecting |
|---|---|
| 20 | 6 ms |
| 22 | 25 ms |
| 24 | 98 ms |
| 26 | 402 ms |
| 28 | 1,494 ms |
| 30 | 6,060 ms |
Every two added characters multiplied the time by roughly four. That is the evidence I look for: not one slow result, but growth that accelerates as the input grows.
Do not run an unknown pattern against unbounded input on a production request thread. Use short, controlled samples in an isolated test process or worker and stop before the duration becomes disruptive.
The two shapes I check first
1. A quantified group containing another quantifier
Examples include:
(a+)+
(\d+)*
([a-z]*)+
Nesting alone is not a proof of exploitation, but it is a strong review signal. I ask whether the inner and outer repetitions create multiple ways to consume the same text.
2. Overlapping alternatives under repetition
This pattern also creates ambiguity:
/^(a|aa)+$/
At many positions, either branch can consume the next characters. Real patterns hide the overlap inside character classes, such as \w|\d, where every digit can match both alternatives.
The question is always the same: can more than one path consume the same input, and can a later failure force the engine to revisit those paths?
Rewrite first, then compare the same hostile input
For the minimal example, the outer group adds no useful behavior:
const vulnerable = /^(a+)+$/;
const repaired = /^a+$/;
Both accept one or more a characters and reject everything else. The repaired version removes the ambiguity.
In the same recorded test:
| Pattern | 30-character hostile input | 100,000-character hostile input |
|---|---|---|
^(a+)+$ |
6,060 ms | stopped rather than attempted |
^a+$ |
0.02 ms | 0.09 ms |
I do not treat those exact numbers as a universal benchmark. I treat the change in growth behavior as the acceptance criterion: the repaired pattern remains approximately linear on the same family of inputs.
My practical review checklist
Before a JavaScript regex processes user-controlled input, I now check:
-
Nested repetition: Does a
+,*, or open-ended range wrap a group that contains another quantifier? - Overlapping alternatives: Can two branches under repetition match the same character or substring?
- A forced failure: Is there an anchor or required literal after the ambiguous part that can trigger exhaustive backtracking?
- Input bounds: Is there a maximum accepted length before the regex runs?
- Measured growth: Does duration remain stable or roughly linear as a hostile input is lengthened?
- Runtime isolation: If patterns or inputs are untrusted, can evaluation happen in a worker, subprocess, or engine with an enforceable timeout?
Length limits matter even after a rewrite. OWASP's input-validation guidance recommends explicit minimum and maximum lengths, and those bounds reduce the damage from mistakes that survive review.
Static checks are useful, but measurement closes the loop
I built a browser-based ReDoS checker around this workflow. It looks for ambiguous structures and can run a bounded timing probe locally in the browser. It does not upload the pattern or test input to a server.
The tool is a review aid, not a security certification. Static detection can produce false positives, while one timing sample can miss an engine-specific worst case. I use both signals, inspect the pattern manually, and compare the repaired version against the same adversarial input.
The longer catastrophic backtracking and ReDoS guide contains the full measurement table and more examples. This DEV version declares that original URL as canonical.
What changed in my maintenance process
The main lesson was to treat regex complexity like ordinary algorithmic complexity. A pattern can be functionally correct on every normal test and still be unsafe under a carefully chosen non-match.
So “the regex returns the right answer” is no longer enough for patterns on untrusted input. I want three pieces of evidence:
- a structural review for ambiguity;
- a bounded test showing how runtime changes with input length; and
- a repaired pattern measured against the same hostile case.
That is a small amount of work compared with diagnosing a production thread that appears frozen while doing exactly what its regex engine was asked to do.
Disclosure: I maintain CodeSwap and the links above point to that project. This article was prepared with AI assistance from the site's documented implementation and test records. Its technical claims and measurements were checked against the live guide, live tool and dated maintenance evidence before publication.
References: OWASP ReDoS explanation, OWASP input-validation guidance, and MDN's JavaScript regular-expression reference.
Top comments (0)