← Back to Blog

Catastrophic Regex Backtracking in .NET: Timeouts and Safer Patterns

How nested quantifiers like (a+)+ cause exponential regex time in .NET, measured on .NET 8, with fixes: match timeouts, NonBacktracking, safer patterns.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
Articles are drafted with AI assistance and reviewed by the author. Code samples and results are run before publication, and each article says how its results were produced.
Published 14 Jul 2026· Last reviewed 14 Oct 2026· 7 min read · About the author →
All timings below were measured on .NET 8.0.31 (Release build) on 14 Oct 2026. Absolute numbers depend on your hardware; the growth rate is what matters.
Key takeaways
  • Catastrophic backtracking happens when a repeated group can match the same text in more than one way, so a failing match tries exponentially many splits
  • Measured: ^(a+)+$ takes about 437 ms at 20 characters and exceeds a 5-second timeout at 24 characters
  • Nested quantifiers are not automatically dangerous: delimiters such as a literal . or {{ }} can make every split unambiguous
  • Fixes: pass a matchTimeout, use RegexOptions.NonBacktracking (.NET 7+), or rewrite the pattern without ambiguous repetition
  • NonBacktracking does not support lookaheads, lookbehinds, backreferences or atomic groups
Table of Contents

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 engineRegexOptions.NonBacktracking
1624 ms23 ms (first call in the process)
20437 ms0.2 ms
24timed out at 5 s0.3 ms
30timed out at 5 s0.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.NonBacktracking when 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.

Try the free tool

Regex Tester

Test regex patterns against sample input with real-time highlighting. Uses the .NET regex engine, the same one your ASP.NET Core code runs.

Open Regex Tester