The regex that freezes the browser — catastrophic backtracking explained
Why a short regular expression can take seconds or hang a page, how to recognize nested quantifiers and how to rewrite the pattern so it stays fast.
By Felipe YamaokaPublished November 5, 20266 min read
A pattern that works until it does not
You write a regular expression to validate a hostname-like value, test it with a few examples and ship it:
const name = /^([a-z0-9]+\.?)+$/;
name.test("api.example.com"); // true, instantly
name.test("not valid!"); // false, instantlyWeeks later a user pastes a long value with a stray character at the end, and the tab stops responding. On a server, the same pattern can pin a CPU core and stop a Node.js process from answering any other request. Nothing is wrong with the input. The problem is the pattern, and it has a name: catastrophic backtracking.
This guide explains why it happens, how to recognize the dangerous shapes and how to fix them, with examples you can paste into the regex tester.
How a backtracking engine works
The regular expression engines in JavaScript, Python, Java, PHP and most other languages are backtracking engines. When a part of the pattern can match in more than one way, the engine picks one option, moves on and, if the rest of the pattern fails later, comes back to try the next option.
Most of the time this is cheap. Take a+b against aaab: a+ grabs the three a, then b matches, done. Against aaac, a+ grabs three a, b fails, the engine gives back one a and tries again, then another. A few steps and it reports no match.
The trouble starts when the number of ways to split the input grows exponentially.
Nested quantifiers: the classic trap
Look at (a+)+$. The inner a+ matches one or more a, and the outer + repeats that group one or more times. A run of four a can be split as aaaa, aaa a, aa aa, a aaa, aa a a and so on: for n characters there are about 2 to the power of n - 1 ways.
When the input is all a, the first split already works and the match is instant. When the input ends with something that makes the match fail, such as aaaaaaaaaaaaaaaaaaaaaaaaaa!, the engine has to prove that none of the splits works, so it tries them all.
Running /(a+)+$/ against a string of a followed by ! on an ordinary laptop gave these times:
- 20 characters: about 9 ms;
- 22 characters: about 34 ms;
- 24 characters: about 136 ms;
- 26 characters: about 400 ms.
Every two extra characters multiply the time by about four. At 30 characters you are waiting seconds; at 40 the page is effectively frozen. The exact numbers depend on the engine and the machine, but the curve does not change.
The hostname pattern from the beginning has the same shape. ([a-z0-9]+\.?)+ is a group with + inside and + outside, and because the dot is optional, a run of letters can be divided among the repetitions in exponentially many ways. With 26 letters and a ! at the end it took about 350 ms in the same test.
The shapes to watch for
You do not need to analyze every pattern by hand. Learn to spot a few shapes:
- A quantified group that contains a quantifier:
(a+)+,(\d*)*,(\w+\s?)+. This is the most common one. - Alternatives that overlap:
(a|aa)+or(\w|\d)+. If two branches can match the same text, the engine can choose between them at every position. - Optional separators between repeated parts:
(\w+\.?)+or(\w+-?)+. Making the separator optional lets the same characters be split between repetitions. - Adjacent quantifiers that compete for the same characters:
\d+\d+$or.*.*=. Less explosive, but still slow on long input.
The common thread is ambiguity: the same piece of text can be matched in more than one way.
How to fix it
The goal is to make each character match in exactly one way.
Make the separator mandatory inside the repetition. Instead of an optional dot after every word, describe the structure directly: a word, then zero or more groups of "dot plus word".
// slow on bad input
const slow = /^([a-z0-9]+\.?)+$/;
// fast: each character belongs to exactly one place
const fast = /^[a-z0-9]+(\.[a-z0-9]+)*$/;The second pattern accepts the same dotted names, such as api.example.com, and rejects aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa! in well under a millisecond, because there is only one way to read the input. It is also stricter in a good way: the original let a trailing dot through by accident, and this one does not.
Remove the redundant quantifier. (a+)+ matches exactly what a+ matches. The outer repetition adds nothing but ambiguity.
Make alternatives exclusive. Rewrite (a|aa)+ as a+, and (\w|\d)+ as \w+, since \w already includes digits.
Use specific character classes instead of .*. "[^"]*" reads a quoted string in a single pass, while ".*" makes the engine try every possible end quote.
Limit the input. A field that should hold a hostname has no reason to accept 10,000 characters. Checking the length before running the expression caps the worst case.
Use a non-backtracking engine where it matters. Some engines, such as Google's RE2 and the regex crate in Rust, guarantee linear time by not supporting backtracking features like backreferences. For patterns that come from users, they are worth the trade.
Why it matters beyond the browser
In the browser, a slow regex freezes one tab. On a server it can take the whole service down. Node.js runs JavaScript on a single thread, so one request that triggers catastrophic backtracking blocks every other request until it finishes. This class of bug is known as ReDoS, regular expression denial of service, and it has affected popular packages and large websites. A pattern used in input validation is exactly where an attacker would aim.
That is also why the Regex Tester on this site runs every pattern in a Web Worker with a one-second limit. If the expression takes longer, the worker is stopped and you see a warning instead of a frozen page. Try (a+)+$ with a long run of a followed by !, then the fixed version, and compare.
A quick checklist
Before you ship a regular expression, especially one that runs on user input:
- Look for a quantifier inside a quantified group, and for alternatives that can match the same text.
- Test it against long input that almost matches: valid characters with one bad character at the end.
- Rewrite ambiguous parts so each character can only match in one way.
- Cap the input length before matching.
- For untrusted patterns or very large inputs, consider a linear-time engine.
Summary
Catastrophic backtracking happens when a pattern lets the same text be matched in many different ways and the input forces the engine to try them all before failing. The warning signs are nested quantifiers, overlapping alternatives and optional separators inside repetitions. Removing the ambiguity, limiting the input and testing with almost-matching strings keeps your regular expressions fast, in the browser and on the server.
Keep reading
Base64 is not encryption — what it is, when to use it and the URL-safe variant
How Base64 turns bytes into text, why it hides nothing, how to handle accents and emoji correctly and when you need the URL-safe alphabet.
November 5, 20265 min readTool: Base64 Encoder/Decoder
From a spreadsheet column to an array in JavaScript, Python or SQL IN
How to turn a pasted column of values into a JavaScript or Python list, a SQL IN clause or CSV, with quoting, duplicates, empty lines and large lists handled safely.
November 5, 20265 min readTool: Column to Array