A regex that works fine in every test you wrote, then locks up a request thread for tens of seconds on one particular piece of user input, is almost always catastrophic backtracking — not a fluke, not a server hiccup. It's a structural property of the pattern itself, and it's exploitable as a denial-of-service vector (ReDoS) if the input comes from outside your app. Here's how to find it and fix it.
Why Backtracking Explodes
Regex engines like JavaScript's, Python's re, and PCRE are backtracking engines: when a pattern has a choice point (a quantifier like +, *, or {n,m}, or alternation with |), the engine tries one option, and if the rest of the match fails, it backs up and tries another. This is normally fast. It becomes exponential when a pattern has nested quantifiers over character classes that overlap, because the same substring can be partitioned by the two quantifiers in many equivalent ways, and the engine tries all of them before giving up.
The canonical example:
const evil = /^(a+)+$/;
evil.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!');
The outer (a+)+ can partition a run of N a characters into groups in a combinatorially large number of ways — 2^(N-1) roughly — and because the string ends with ! instead of matching $, the engine has to exhaust every partitioning before concluding there's no match. Add a handful more characters to the input and the runtime goes from instant to something that will hang the process. This is precisely the shape of a ReDoS: an attacker doesn't need to break anything, just submit one string that hits this pattern.
Recognizing the Pattern in Your Own Regex
You're looking for nested quantifiers where the inner and outer character classes can both match the same characters. Common real-world shapes:
(a+)+ # classic - outer and inner both match 'a'
(a|a)* # alternation with overlapping branches
(a*)* # same issue, different quantifier
(\d+\s*)+ # if input can be all digits, \s* matches zero of them,
# so \d+ alone could partition the same digit run many ways
([a-zA-Z]+)* # very common in "validate a word" patterns copied from tutorials
A pattern like \d{3}-\d{4} (a phone number format) has no nesting and no overlap — it's linear regardless of input, and not a risk. The danger is specifically nested repetition over ambiguous partitions, not regex complexity in general.
Reproducing Safely
Don't test a suspected catastrophic pattern against production or even your normal dev server — a genuinely vulnerable regex can hang the process for a very long time on a moderately sized input, and in Node, regex execution blocks the single JS thread, taking down everything else being served by that process too.
Test in an isolated script with a hard timeout, or a worker you can kill:
// isolated-test.js — run with: node isolated-test.js
const pattern = /^(a+)+$/;
const input = 'a'.repeat(30) + '!';
const start = Date.now();
console.log('starting...');
pattern.test(input); // if this pattern is vulnerable, this line hangs
console.log('done in', Date.now() - start, 'ms');
Increase the repeat count gradually (25, 30, 35...) rather than jumping straight to a large number — with true exponential blowup, each additional character roughly doubles the runtime, so you'll see the problem well before you need a huge input to prove it.
Fixing It: Three Approaches
1. Remove the ambiguity by making groups mutually exclusive or atomic. If the inner and outer quantifiers can't both consume the same characters, there's only one way to partition the string and no exponential blowup:
// Vulnerable
/^(a+)+$/
// Fixed — a+ alone is linear, no nested quantifier needed at all
/^a+$/
Most real-world instances of this bug are exactly this: a nested quantifier that was never actually needed, often introduced by over-eager copy-paste from a "flexible" pattern template.
2. Use possessive quantifiers or atomic groups where your engine supports them. These tell the engine "once this group matches, never backtrack into it," which eliminates the combinatorial retry. JavaScript doesn't support these natively as of the current spec, but you can approximate atomic grouping with a lookahead trick:
// Atomic group emulation: (?=(a+))\1
const fixed = /^(?=(a+))\1$/;
Python 3.11+ and PCRE support possessive quantifiers directly (a++), which is cleaner where available.
3. Rewrite to avoid nested repetition entirely by being specific about what varies. Instead of (\d+\s*)+ to match "digits with optional whitespace, repeated," be explicit about the separator so there's no ambiguity about which part consumes the whitespace:
// Vulnerable: \d+ and \s* can both match nothing, creating ambiguity
/^(\d+\s*)+$/
// Fixed: whitespace is only ever a separator between digit groups
/^\d+(\s+\d+)*$/
When to Reach for a Different Engine Instead
If your application processes regex patterns that aren't fully under your control — user-submitted search patterns, configurable validation rules from a CMS — rewriting every possible bad pattern isn't feasible. In that case, the more robust fix is switching the matching engine itself: RE2 (available in Node via the re2 package) and Go's built-in regexp package use finite-automaton matching that guarantees linear-time execution regardless of pattern shape, at the cost of not supporting backreferences and a few other backtracking-specific features.
Testing Your Fix
Once you've rewritten a pattern, verify both correctness and performance against the same set of inputs — the fix shouldn't just avoid hanging, it needs to still match what it's supposed to match. The Regex Tester is useful for quickly checking a pattern against several sample strings side by side while you compare the original and rewritten versions, before you commit the fix and move on to auditing the rest of your codebase for the same nested-quantifier shape.
Grep your codebase for the shape (...+)+, (...*)*, or repeated groups with overlapping character classes — it's a search worth running once across every regex literal in the project, not just the one that already caused an incident.