What catastrophic backtracking is
The default .NET regex engine is a backtracking engine. When part of a pattern can match in several ways, it tries one way, and if the overall match later fails, it goes back and tries the next. For most patterns that is cheap. For a pattern where a repeated group can split the same text in many different ways, the number of ways grows exponentially with the input length, and a failing match has to try all of them before it can give up. This is the cause of ReDoS (regular expression denial of service): one slow input can pin a CPU core.
Measured: the classic dangerous patterns
The pattern ^(a+)+$ applied to a string of a characters followed by one ! never matches, but the engine does not know that until it has tried every way of dividing the as between the inner and outer repetition. Timings on .NET 8, with a 5-second match timeout:
| Input length (a…a!) | Backtracking engine | RegexOptions.NonBacktracking |
|---|---|---|
| 16 | 24 ms | 23 ms (first call in the process) |
| 20 | 437 ms | 0.2 ms |
| 24 | timed out at 5 s | 0.3 ms |
| 30 | timed out at 5 s | 0.1 ms |
A more realistic-looking pattern behaves the same way. ^(\w+\s?)*$ is the kind of expression people write to validate "words separated by optional spaces". On a string of repeated word followed by !, it exceeded the 5-second timeout at 18 words (the smallest size tested), and the non-backtracking engine finished in about 0.1 ms.
Each extra character roughly doubles the time. That is why the failure looks like "fine for months, then one request hangs": ordinary inputs match or fail quickly, and only a near-miss of the right shape triggers the explosion.
Nested quantifiers are not always the problem
It is tempting to treat any pattern with a quantified group inside a quantified group as dangerous. That is not accurate. Consider this template-expression validator, which has three levels of nesting:
^(\s*\{\{(\w+\.)*\w+(\s*\|\s*\w+)*\}\}\s*)+$
With an input of 30 dot-separated names followed by a !, it rejected the string in about 0.0 ms. The reason is that the literal . inside (\w+\.)* and the literal {{ }} delimiters mean the text can only be divided one way. There is nothing ambiguous to retry. The dangerous shape is a repetition whose inner and outer parts can match the same characters, as in (a+)+ or (\w+\s?)*, where the optional or overlapping part lets the engine re-split the same run of letters.
A useful test for any pattern: can two different ways of repeating the group consume the same stretch of input? If yes, a near-miss input can make it exponential.
Fix 1: always set a match timeout
The constructor overload that takes a timeout makes the engine throw RegexMatchTimeoutException instead of running forever. Treat the exception as "invalid input".
private static readonly Regex Words = new(
@"^(\w+\s?)*$",
RegexOptions.None,
TimeSpan.FromMilliseconds(200));
public static bool IsValid(string input)
{
try { return Words.IsMatch(input); }
catch (RegexMatchTimeoutException) { return false; }
}
For source-generated regexes, pass the timeout to the attribute (this compiles on .NET 8):
[GeneratedRegex(@"^[\w ]*$", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
public static partial Regex WordsAndSpaces();
You can also set an application-wide default with the REGEX_DEFAULT_MATCH_TIMEOUT AppContext switch. A regex created without a timeout then reports the configured value (200 ms in the test) and throws RegexMatchTimeoutException on the bad input. It has to be set before the first regex is used.
Fix 2: RegexOptions.NonBacktracking (.NET 7+)
This option uses an automaton-based engine whose matching time is linear in the input length, so catastrophic backtracking cannot happen. In the table above it stayed at fractions of a millisecond for every input. The cost is feature support. Four constructs were tested and each one threw NotSupportedException when combined with the option:
- lookahead,
(?=a)a - lookbehind,
(?<=a)b - backreferences,
(a)\1 - atomic groups,
(?>a+)
[GeneratedRegex(@"^(\w+\s?)*$", RegexOptions.NonBacktracking)]
public static partial Regex NonBacktrackingWords();
Fix 3: rewrite the pattern so nothing is ambiguous
Often the best fix is a simpler pattern. If all you need is "only word characters and spaces", a single character class does the job with no nesting at all. On a 25,000-character input ending in ! it rejected the string in about 0.2 ms:
^[\w ]*$
When a pattern has to describe structure (such as the {{ ... }} template above), split the work: use one simple regex to find each block, then check the content of each block with a second simple regex, instead of one regex that tries to describe the whole string.
Test with near-miss inputs
Happy-path tests will never reveal this. For every user-facing pattern, add a test with a long input that is almost valid (a long run of valid characters followed by one invalid one) and assert that it finishes quickly or is rejected by the timeout. You can try patterns interactively in the Regex Tester, which runs the .NET regex engine.
Summary
- Danger comes from ambiguous repetition, not from nesting alone;
^(a+)+$and^(\w+\s?)*$are exponential, the delimiter-based template pattern above is not. - Set a timeout on every regex that sees untrusted input.
- Use
RegexOptions.NonBacktrackingwhen you do not need lookarounds, backreferences or atomic groups, or rewrite the pattern.
Update, 14 Oct 2026: an earlier version of this article presented the topic as a first-person production incident. That story was not a real event and its headline pattern turned out not to backtrack at all when measured, so the article has been rewritten around timings that can be reproduced.