Catastrophic Backtracking, Explained

Why (a+)+b makes a JavaScript regex hang on a 30-character string — the exponential retry mechanism, how to recognize the shape, and the rewrites that fix it.

Published 2026-09-25

Try this on the tester home page: pattern (a+)+b, test string aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX — thirty as and an X. On a real page with a heavy enough input, the tab freezes. This article explains the mechanism well enough that you’ll spot the shape in code review.

What the engine is doing

JavaScript’s regex engine is a backtracking engine: it tries the pattern left to right, remembers every point where it had a choice, and when a match fails it walks back to the last choice and tries differently. For (a+)+b on aaaa…X:

  1. The inner a+ eats all thirty as.
  2. The outer + loops — can it match again? No characters left, so the group stops at one iteration.
  3. b needs to match X. It fails.
  4. Backtrack: the inner a+ gives back one character (now 29), the outer + tries a second iteration and eats the leftover a.
  5. b fails again. Backtrack: try the inner a+ at 28 and the second iteration eating 2. Then inner 29 / second 1. Then three iterations…

Each way of splitting the thirty as into groups is a distinct path — and there are 2^29 of them (each a boundary either ends a group or doesn’t). Add one a and the work doubles. At 30 characters it’s seconds; at 40 it’s hours; at 60 it’s longer than the sun will burn. This is why a regex can pass every unit test and melt production: matching inputs succeed on the first try, and only the near-miss input — a long run that almost fits then fails — detonates the bomb.

The shapes to recognize

  • Nested unbounded loops: (a+)+, (\w+)*, (a*)*, (\d+)* — a quantified group containing an unbounded quantifier. The two loops can divide characters arbitrarily between them.
  • Ambiguous alternation under a loop: (a|aa)+, (\w|\s)+, (foo|foobar)* — the same input characters fit several branches, so the engine retries every branch combination per position.
  • Adjacent loops over overlapping content: \w+\s*\w+-style sequences or .*.* — the boundary between the two is another choice point to enumerate.

The common thread: more than one way to consume the same characters. If each input character has exactly one home in the pattern, there is nothing to enumerate and the match is linear.

The fixes, in order of preference

  1. Collapse the redundancy. (a+)+ means exactly what a+ means — the group was noise. (\w+)*\w* (the nested version also matched the same language; the wrapping was doing nothing but making it slow).
  2. Make the homes disjoint. If the group is (?:\w+|\s+)+, every character fits exactly one alternative — no ambiguity, linear time. Rewriting (a|aa)+ as a+ does the same job.
  3. Bound the inner loop. (a{1,4})+ still has the shape but caps each group’s size — the enumeration shrinks from exponential to a small constant factor. Use when you genuinely need grouped structure.
  4. Bound the input. A length cap before matching (input.length < 200) is a pragmatic guard for patterns you can’t rewrite.

JavaScript lacks the structural fixes other engines offer — atomic groups (?>…) and possessive quantifiers a++ tell a backtracking engine “don’t revisit this choice”, which defuses these shapes directly. Since they don’t exist in JS, rewriting for disjointness is the real fix, not a workaround.

How the tester detects it

The backtracking warning on the tester is a heuristic over the pattern text: it finds each quantified group ((…)+, (…)*, (…){n,m} with m>1), then checks whether the group contains an unbounded quantifier inside, or a top-level | whose branches can start with the same character. Both fire a warning naming the offending fragment. It’s deliberately honest about being a heuristic — a warning means “test this with a long failing string”, not “this is definitely exponential”, and plenty of bad patterns (like \w+\s*$ pathologies) slip through. The thirty-second test it suggests: run your pattern against a 40-character string that fails by one character. Instant = fine; frozen = rewrite.

Frequently asked questions

What is catastrophic backtracking?

When a regex can match the same text in exponentially many ways, a failing input forces the engine to try them all before giving up. (a+)+b on 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX' explores ~2^n divisions of the a's between the inner + and the outer + — each added character roughly doubles the work. It looks like the tab froze; it's actually just busy failing.

Why doesn't JavaScript just optimize this away?

Because backtracking is the specified semantics — JS regexes are defined as backtracking engines so backreferences and lookarounds can exist at all. Automata-based engines (RE2, used in Go) run in guaranteed linear time but can't support those features. V8/JSC do have optimizations for simple cases, but none that change the worst case here.

Does a bad pattern only blow up on weird inputs?

The detonation needs a match that ALMOST works then fails — 'aaaa…a' followed by one wrong char. Fully matching inputs return fast (the first try wins), fully different inputs fail fast too. The kill zone is the near-miss: long prefix matches, then a character the pattern can't account for.

How do I fix (a+)+ once I've written it?

Collapse it — the group adds nothing, so a+b is identical in meaning and linear. The general fixes: make inner/outer loops disjoint so characters fit exactly one loop ((?:a+|b+)+ is still ambiguous — a* gives each char one home); bound the inner loop ({1,4}); or restructure so alternatives can't start the same way. Test the rewrite on a long failing string.

Is this a real security problem?

Yes — it's called ReDoS (regex denial of service). A pathological pattern behind an unbounded user-supplied input can pin a CPU core per request; Node's single-threaded event loop makes it worse than a thread-per-request model. It's CVE-catalogued in popular libraries — the validator.js CVE-2013-era bugs were exactly this shape.

Why can't I just add a timeout to a JS regex?

There's no built-in timeout — a regex runs synchronously and can't be interrupted. Your options are a Worker (killable), validating inputs by length before matching, using a linear-time engine, or just writing patterns that don't nest unbounded loops. Prevention beats containment here.