Why Backtracking Regex Patterns Can Silently Hang Your Server
Learn how greedy matching and catastrophic backtracking can turn a seemingly correct regex into a CPU-pegging production outage, and how to spot the risk.
This article is published in English.
Sources: The Regex That Looked Fine in Testing and Hung the Server in Production
A username validation pattern went through code review, sailed through the entire test suite, and then, weeks after deployment, brought an API endpoint to a standstill for eleven seconds on a single incoming request. There was no crash, no thrown exception — the process simply stopped responding, sitting at 100% CPU while it churned through a string that was barely forty characters long.
No bug had been introduced in the usual sense. The regex itself was valid and matched exactly what it was supposed to match. But it carried a hidden trait that most developers never learn to watch for, and making sense of it means looking past the symbols and understanding what a regular expression really does when it runs, something most tutorials skip over on their way to explaining tokens like \d and +.
A Regex Is a Tiny Flowchart, Not a String of Symbols
Consider a pattern meant to validate a product code made of two capital letters followed by four digits:
const productCode = /^[A-Z]{2}\d{4}$/;
console.log(productCode.test("LM4821")); // true
console.log(productCode.test("LM482")); // false
It's natural to think of this as a shorthand description of a string shape. A better mental model, and one that pays off later, is to treat it as a miniature flowchart: begin, demand exactly two uppercase letters, then demand exactly four digits, then demand the input end. The engine steps through the input one character at a time, attempting to trace a route through that diagram from the start node to the finish node. When it reaches the end successfully, that's a match. When it runs into a wall and there's still an untried branch somewhere behind it, it retreats and tries that other branch instead.
That retreating step — going back to test an alternate branch — is known as backtracking, and it's arguably the single most consequential concept in regular expressions. It's what gives them their flexibility, and it's also exactly what caused the production incident described above.
Groups Turn "Does It Match" Into "What's Actually In There"
Calling .test() only returns true or false. Wrapping parts of a pattern in parentheses lets you capture pieces of the matched text individually, which is where regex becomes genuinely useful for parsing rather than just validating:
const logPattern = /^\[(\w+)\] (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}) (.+)$/;
const line = "[ERROR] 2026-08-15T10:22:01 Failed to connect to database";
const match = logPattern.exec(line);
console.log(match[1]); // "ERROR"
console.log(match[2]); // "2026-08-15T10:22:01"
console.log(match[3]); // "Failed to connect to database"
Every parenthesized section of the pattern becomes its own entry in the result array, ordered by where its opening parenthesis appears in the pattern. This turns a simple pass/fail check — confirming that a line has the shape of a log entry — into something far more useful: having the severity level, the timestamp, and the message body available as three distinct values ready to be used. That capability is why regular expressions show up so often in log processing, configuration parsing, and any situation where loosely structured text needs to be converted into something a program can work with directly.
Greedy Matching Grabs More Than You Meant It To
This next mistake is one nearly every developer makes at least once. Imagine a case where lightweight HTML tags need to be stripped out of a text field:
const html = "<b>Featured</b> item, <i>on sale</i> now";
console.log(html.replace(/<.*>/g, ""));
// "Featured item, on sale now" — wait, that actually looks right...
const html2 = "<b>Sale</b> ends <i>Friday</i>";
console.log(html2.replace(/<.*>/g, ""));
// "" — everything vanished
The second string exposes the flaw. By default, .* is greedy — it tries to swallow as many characters as it possibly can, only giving characters back if the rest of the pattern absolutely requires it. Because this string contains two separate tags, the pattern <.*> doesn't stop at the first closing </b>. It keeps consuming everything after it — past ends, straight through to the very last > in the string, inside </i> — since a single greedy .*> is fully satisfied as long as it lands on the last available > anywhere in the text, no matter how far away that is.
console.log(html2.replace(/<.*?>/g, ""));
// "Sale ends Friday"
Placing a ? right after the * flips it from greedy to lazy: the engine now tries to match as few characters as possible, expanding only when it has no other way to satisfy what comes next. With that change, each <...> sequence closes at the nearest > instead of racing to the last one in the whole string, so tags are removed one at a time as intended. This one-character distinction, * versus *?, quietly accounts for a large fraction of the "regex deleted way more text than expected" bugs developers run into.
The Bug That Actually Hung the Server
Consider a stripped-down version of the pattern responsible for that eleven-second freeze: a username check meant to require one or more chunks of word characters, possibly broken up, followed by an @ sign.
const badPattern = /^(\w+)+@/;
console.log(badPattern.test("valid_user@example.com")); // true, fast
console.log(badPattern.test("this_is_a_fairly_long_username_with_no_at_sign"));
// hangs, sometimes for a very long time, depending on length
The trouble lives in the nested repetition: (\w+)+, a run of one-or-more word characters, itself wrapped inside a group that repeats one-or-more times. When the input string genuinely contains an @, the engine finds a match almost immediately. But feed it a string with no @ at all, and the engine can't just give up right away, it has to exhaustively demonstrate that no possible arrangement works. And there are a staggering number of arrangements to check: the inner + could grab one character while the outer group repeats over the rest, or it could grab two characters and let the outer group repeat over what's left, and so on, every possible split gets tried. The engine backtracks through each of these combinations before it's willing to declare failure, and each additional character roughly doubles how many combinations exist. That means a forty-character username that doesn't match isn't merely ten times slower to reject than a four-character one, it's slower by a factor that grows exponentially, which explains how a regex that sailed through testing, where it only ever saw valid usernames, can suddenly lock up the moment it receives a long string it was never meant to match.
const fixedPattern = /^\w+@/;
Once you understand the root cause, the fix looks almost too simple: the nested repetition was never necessary to begin with. \w+ on its own means exactly the same thing as (\w+)+ for any input that's actually supposed to succeed, but without the exponential backtracking risk hiding underneath it. This failure has a well-known label, catastrophic backtracking, and the shape to watch for is any repetition operator applied to a group that already contains its own repetition operator, patterns like (x+)+, (x*)+, or (x+)* are all variations on the same underlying trap.
What This Actually Teaches You to Watch For
You don't need to memorize every dangerous pattern shape that exists. What matters is internalizing a single underlying truth: a regex engine doesn't evaluate a pattern instantly, it searches for a match, one path at a time, retreating and trying a different path whenever the current one fails. Ordinary patterns have so few paths to explore that this search wraps up before you'd ever notice a delay. The real danger shows up specifically with nested, overlapping repetition, where the number of paths to check doesn't just increase steadily, it multiplies with every extra character of input that doesn't behave. So test your regexes not only against the strings you expect to match, but also against long strings deliberately built to fail. Whatever input your test suite never thought to try is precisely the input that eventually shows up in production.