Regex Patterns and Gotchas: Catastrophic Backtracking and the Rest
Regex bugs come in two flavors: the match isn't what you meant, or the engine stops answering at all. This page is the field guide to both — JS-focused, since that's the engine running in your app, and each entry ends with the fix, not just the story.
Catastrophic backtracking: the regex that hangs your server
Start with the canonical killer:
const re = /^(\w+\s?)+$/;
re.test('aaaaaaaaaaaaaaaaaaaaaaaaaa'); // fine
re.test('aaaaaaaaaaaaaaaaaaaaaaaaaa!'); // ...still thinking
The engine tries to consume the whole string with (\w+\s?), succeeds one way, then discovers the $ anchor fails at the ! — so it backtracks and re-partitions the a's every possible way: \w+ can split aaa as a|aa, aa|a, aaa, and the outer + tries every composition of those splits. With n characters that's ~2^n paths. Thirty a's is seconds; forty is minutes; your event loop is now a parking lot.
The diagnosis signature: linear input, exponential failure; it only hangs on the input that doesn't match. The structural fix is to remove the nested quantifier — /^\w+(?:\s\w+)*$/ accepts the same language in one pass. Engines can't always save you (PCRE has possessive/atomic groups; JS has neither; RE2 and Rust's regex crate simply refuse the pattern at compile time — the design tradeoff in engines compared). Paste suspicious patterns into the tester with aaaa...! input and watch the timer before shipping them.
The alternation that hides a branch
/error|warn|error critical/ — the third branch can never match, because the first one already matches its prefix and alternation is first-match-wins, longest-be-damned. Reorder longest-first (error critical|error|warn) or, better, replace the whole alternation with the character class of what's actually legal when branches overlap that much.
Capture renumbering: insert one group, break every consumer
You wrap (?:...) around two groups to fix precedence, forget the ?:, and every .slice(1) and $2 downstream now reads shifted values — with no error. Two defenses: name your groups ((?<id>...)) and read .groups, or non-capture by default and capture on purpose. The tester's group inspector is the cheap pre-commit hook.
g + test() = the stateful lie
const re = /x/g;
re.test('x'); // true
re.test('x'); // false — lastIndex is past the match
lastIndex advances on g/y-flagged regexes, and test/exec resume from there on the same object. Two calls, same string, different answers. For "does it match at all," drop the g. For iteration, prefer String.prototype.matchAll — it clones the regex per call, which is the behavior everyone assumed g.test had.
split with a capturing group keeps the captures
'a1b2c'.split(/(\d)/); // ['a', '1', 'b', '2', 'c'] — separators included!
'a1b2c'.split(/\d/); // ['a', 'b', 'c'] — what you meant
The capture-preserving behavior is documented, under-known, and the difference between a CSV tokenizer that works and one that hallucinates columns. Use (?:...) when you see it.
The empty-match loop that never ends
while ((m = /a*/g.exec(s)) !== null) { ... } // s='bbb': matches '' forever
Zero-width matches advance lastIndex by 1 in matchAll/split (spec'd) but by nothing in raw exec loops you wrote yourself. Guard: if (m.index === re.lastIndex) re.lastIndex++; — or don't hand-roll what matchAll does correctly. This is why /(\d*)/-style "optional everything" patterns turn tokenizers into infinite loops.
Character class lies: ranges you didn't mean
[a-Z] is a valid range from a(97)... except Z is 90 < 97: empty range in most engines, error in some, and in [A-z] it silently includes [\]^_. Likewise .-in-class ([a.d] is three characters, not a class-and-dot) and $`-in-class (literal). When a class stops fitting in your head, write the alternation; when it's a range, write the two endpoints of what you mean, not what looks tidy.
FAQ
How do I test for ReDoS in CI? Static lint first (safe-regex, eslint's no-redos, RE2-compile-check as a CI gate), then timing tests: assert pattern-executes-under-100ms on a 1k-character adversarial corpus generated from the pattern's alphabet. The email route patterns on this site all pass that gate; most pasted Stack Overflow patterns don't.
Is catastrophic backtracking possible with u flag? The flag changes matching semantics, not the algorithm — the engine still backtracks, so no. It's a design property, not a mode.
Does anchoring fix ReDoS? No. Anchors cut the scan, not the backtrack: ^...$ still re-partitions internally. Remove the nested quantifier; see best practices for the rewrites.