Catastrophic Backtracking
Learn what causes catastrophic backtracking in JavaScript regular expressions and how nested quantifiers like (a+)+ blow up exponentially.
Catastrophic backtracking is a phenomenon in regular expressions where the engine takes an excessive amount of time to evaluate certain patterns, leading to significant performance degradation — sometimes seconds, minutes, or effectively forever. It happens because the engine tries to match parts of the string in many different ways before giving up, and the number of combinations it explores grows exponentially with the input length.
This page covers what triggers the problem, how to recognize a dangerous pattern, and concrete techniques to rewrite a regex so it stays fast. It assumes you are comfortable with quantifiers and the difference between greedy and lazy matching.
Why it matters: A single slow regex applied to user-supplied input is a real denial-of-service vector (often called "ReDoS"). An attacker only needs to send a short string crafted to maximize backtracking to freeze your Node.js event loop.
What Causes Catastrophic Backtracking?
Catastrophic backtracking typically occurs with nested quantifiers — a quantifier inside a group that is itself quantified, such as (a+)+. The danger appears when two parts of the pattern can match the same characters, so the engine has many overlapping ways to divide the input. When the match ultimately fails, the engine must try every one of those divisions before it can conclude that nothing matches. Here is the classic example:
If it does not take a long time on your computer, you can add another a character to the str. So why is this taking so much time? Let us analyze it. The pattern /^(a+)+$/ consists of:
^which asserts the position at the start of the string.(a+)which matches one or moreacharacters.+which allows the previous group(a+)to repeat one or more times.$which asserts the position at the end of the string.
Now the matching process is:
- Initial Match: The engine starts at the beginning of the string (
^). - First Group Match: The engine matches the first
a+, consuming allacharacters (aaa...). - Outer Quantifier: The outer
+allows the engine to repeat the(a+)group.
When the engine reaches the exclamation mark (!), it cannot match it with the pattern, causing the match to fail. At this point, backtracking begins:
- Backtracking Attempt: The engine backtracks to repeatedly split the matched
acharacters between the innera+and outer+quantifiers. It re-evaluates each split to see if a different partition can match the pattern up to the end of the string. - Exponential Growth: This backtracking process can grow exponentially as the engine tries every possible way to partition the string of
acharacters into different groups that could potentially match(a+)+.
For a string of n a characters, the inner a+ and outer + can split those characters into groups in roughly 2^(n-1) different ways. When the trailing ! makes the match fail, the engine has to try all of them. That is why adding a single extra a to the input roughly doubles the running time — the hallmark of exponential blow-up. The match below succeeds quickly because there is no failing tail to force a full exploration:
The lesson: catastrophic backtracking only bites when a pattern can match many ways and the overall match eventually fails. Crafted failing input is exactly what an attacker sends.
Identifying Patterns Prone to Catastrophic Backtracking
As a quick mental checklist, a pattern is at risk when it has all three of these traits: a repetition, inside another repetition, over characters that overlap. Common red flags:
- Nested quantifiers, e.g.
(a+)+,(\d*)*,(\w+)*. - Quantified groups containing an alternation that overlaps, e.g.
(a|a)+or(\w|\d)+(\walready includes\d). - A greedy
.*or.+between two things that can also match the same characters, e.g.<.+>.*<.+>. - Unanchored patterns on long input, which retry the whole match at every starting position.
If a quantified group's repetitions can each match the same substring, you have ambiguity, and ambiguity is what backtracking explores.
Strategies to Prevent Catastrophic Backtracking
The goal of every fix below is the same: remove the ambiguity that lets the engine divide the input in more than one way. Switching greedy to lazy (+?, *?) does not help here — both still explore every split; lazy just explores them in a different order. You need to change the structure of the pattern, not its greediness.
1. Eliminate the Nested Quantifier
(a+)+ is almost always equivalent to a single quantifier. If you only need "one or more a", just write a+. There is exactly one way to match that, so the engine cannot backtrack into a combinatorial explosion.
2. Emulate an Atomic Group with Lookahead
JavaScript has no built-in atomic groups (?>...) or possessive quantifiers (a++) like some other regex flavors. You can reproduce the same "match this once and never give it back" behavior with a lookahead plus a backreference: (?=(a+))\1. The lookahead matches a+ greedily, captures it, and \1 consumes exactly that text — but because the captured group was inside a lookahead, the engine will not re-partition it on backtracking.
3. Use Specific, Non-Overlapping Character Classes
Backtracking explodes when adjacent parts of a pattern can match the same characters. Make each part match a distinct set so there is only one way to split the input. For example, prefer \d+\.\d+ over [\d.]+\.[\d.]+, where both [\d.]+ groups compete for the same dot.
4. Anchor and Bound the Pattern
Anchoring with ^ and $ lets the engine fail fast instead of re-trying the match at every position in the string. Putting an explicit upper bound on a quantifier (a{1,20} instead of a+) caps how much work any single repetition can generate.
Practical Examples and Solutions
Example 1: Matching Nested HTML Tags
A common use case for regex is matching nested HTML tags, which can easily lead to catastrophic backtracking if not handled properly. Note: Regular expressions are generally unsuitable for parsing arbitrary or deeply nested HTML structures; use a proper HTML parser for complex documents.
Problematic Pattern
Improved Pattern
Replace the greedy .* (which can swallow the whole document and then crawl back) with a class that cannot cross the closing bracket. [^<]* matches everything up to the next <, so there is no overlap to backtrack through.
Example 2: Validating an Identifier List
Problematic Pattern
([a-zA-Z0-9_]+)+ is the same trap as (a+)+: the inner + and outer + both repeat over the same characters, so a long no-match input triggers exponential backtracking.
A Safe Alternation
Not every quantified group is dangerous. (?:ab|cd)+e is fine: ab and cd are disjoint, so the engine never has to second-guess how it split the input. Use a non-capturing group (?:...) when you do not need the captured text — it is slightly faster and clearer, even though it does not change the backtracking behavior here.
Conclusion
Catastrophic backtracking can freeze a JavaScript application — and because it is triggered by input, it is a genuine security risk, not just a performance one. The fix is almost always to remove ambiguity: flatten nested quantifiers, make adjacent parts of the pattern match disjoint character sets, anchor with ^/$, bound your repetitions, or emulate an atomic group with (?=(...))\1. Switching to lazy quantifiers does not help.
When you write a regex that runs against untrusted input, test it with long failing strings (e.g. a hundred as followed by !) and watch the timing. If adding one more character noticeably increases the time, the pattern is exponential and needs restructuring.
To go deeper, review related chapters on quantifiers, greedy and lazy quantifiers, capturing groups, and character classes.