/^/

Lesson 22 of 28

Performance

Catastrophic backtracking, why nested quantifiers over overlapping character sets are dangerous, and practical habits for keeping regex fast.

6 min read

A regex can be perfectly correct — matching exactly the strings it should — and still be a performance disaster. This lesson is about that second failure mode: patterns that are right but dangerously slow on certain inputs.

Catastrophic backtracking

Recall from the regex engines lesson that JavaScript uses a backtracking engine: when a match attempt fails partway through, the engine undoes its last choice and tries another path. Most of the time this is cheap. But some patterns create so many equivalent paths that the engine’s exploration grows exponentially with the input length.

The classic example is /(a+)+$/ tested against a string of many a characters followed by something that isn’t an a:

// DO NOT run this against real input — it can hang for minutes or longer
/(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!");

Here’s why it explodes: the inner a+ can claim anywhere from 1 to all of the a’s, and the outer (...)+ can then repeat over whatever’s left, claiming its own a’s in yet more ways. For a run of n a’s, there are roughly 2ⁿ different ways to partition them between the inner and outer quantifiers — and since the string is followed by ! instead of the end of string, every single one of those partitions eventually fails, forcing the engine to try them all before giving up. Add just a few more a’s to the input and the runtime doubles.

Watch out

This is called catastrophic backtracking, and when it’s triggered by attacker-controlled input (a form field, a URL, a search box) it’s a real denial-of-service vulnerability known as ReDoS. It has caused real production outages — this isn’t a theoretical concern.

Why nested quantifiers are the usual culprit

The danger sign is a quantifier inside a group that is itself quantified, where the inner and outer pattern can match overlapping sets of characters — (a+)+, (a*)*, (\w+\s*)+, and similar shapes. Each layer multiplies the number of ways the engine can divide up the same stretch of input. If the inner and outer character sets don’t overlap (like (\d+[a-z]+)+, where digits and letters can’t be confused for each other) there’s far less ambiguity to explore, so the risk drops sharply.

A faster, equivalent rewrite of the pathological example is simply /a+$/ — one quantifier, no nested repetition, no ambiguity about how the a’s are grouped:

/a+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!");
// false, and returns instantly — no nested quantifier, nothing to explore

Practical habits for fast patterns

  • Anchor when you can. ^...$ lets the engine reject a non-matching string quickly instead of retrying the pattern at every starting position in the string.
  • Avoid nested quantifiers over the same characters. Prefer a+ over (a+)+; prefer \d+ over (\d)+ when you don’t need each digit captured individually.
  • Use non-capturing groups when you don’t need the text back. (?:...) groups repetition without the (small but real) overhead of recording a capture.
// Non-capturing group used purely for repetition, not extraction
const re = /(?:https?:\/\/)?(?:www\.)?example\.com/;
visit www.example.com today visit http://example.com today
2 matches
  • Emulate atomic groups when you need them. JavaScript has no native atomic groups ((?>...)) or possessive quantifiers (a++), the tools other engines use to say “match this and never backtrack into it.” You can approximate the effect with a lookahead-and-backreference: (?=(a+))\1 matches greedily via the lookahead, then re-consumes exactly what it captured, without the engine considering shorter alternatives on backtrack.
  • Precompile patterns outside hot loops. Writing text.match(/foo/) inside a loop that runs thousands of times re-parses the same literal pattern each time in some engines; hoisting const re = /foo/ above the loop and reusing it is both clearer and avoids redundant work.
// Slower: pattern re-created (or re-parsed) on every call
function isValid(s) {
  return /^[a-z0-9-]+$/.test(s);
}

// Better: compiled once, reused on every call
const SLUG_RE = /^[a-z0-9-]+$/;
function isValid(s) {
  return SLUG_RE.test(s);
}

Try it yourself

Without running it (per the warning above, this one can lock up a browser tab), work through /^(a|a)+$/ against a long string of a’s followed by a b. Why is this just as dangerous as (a+)+$, even though it uses alternation instead of a nested +?

What should happen

(a|a)+ offers the engine two identical branches (a or a) for every repetition, so for a string of n a’s, there are 2ⁿ different sequences of branch choices that all consume the same characters. Since the trailing b makes the overall match fail, the engine ends up trying all of them before giving up — the same exponential blowup as (a+)+$, just triggered by redundant alternation instead of a nested quantifier. The lesson generalizes: any construct that gives the engine multiple equivalent ways to consume the same substring is a backtracking risk, regardless of which syntax creates the ambiguity.

What’s next

Writing fast patterns is half the job — the next lesson is about writing readable, maintainable ones: naming your groups, testing edge cases deliberately, and knowing when not to reach for regex at all.

Quick check

What makes a pattern like `(a+)+$` dangerous against a long run of 'a' characters with no trailing match?

Which technique does JavaScript provide as a workaround for the lack of native atomic groups / possessive quantifiers?