Most regular expressions run in microseconds and you'll never think about their performance again. But a small class of patterns can go from instant to hanging the process on a specific input — and because that input is often something a user typed into a form, a slow pattern isn't just an inefficiency, it can be a denial-of-service vector. Here's what actually matters for regex performance in practice.
The one that matters most: catastrophic backtracking
JavaScript's regex engine is backtracking: when a match attempt fails partway through, it rewinds and tries a different way of satisfying earlier quantifiers before giving up entirely. For most patterns that's cheap. But if a pattern has nested or overlapping quantifiers — a quantifier around something that's itself quantified, or two ways of matching the same run of characters — the number of ways to fail can grow exponentially with the input length.
The textbook example:
/^(a+)+$/Against a string of 30 "a"s followed by one character that breaks the match (say, "b"), this pattern doesn't just fail fast — it tries every possible way of splitting those 30 a's across repetitions of the inner group before it can be sure none of them work. That's roughly 2³⁰ attempts for 30 characters; add a few more and you're well past the age of the universe. This is called ReDoS (Regular Expression Denial of Service), and real validation regexes — email patterns being a repeat offender — have shipped with exactly this shape and taken production services down when someone submitted the wrong input.
How to spot it
Look for these shapes in a pattern, especially ones that run against untrusted input:
- Nested quantifiers:
(a+)+,(a*)*,(a+)*— a repeated group where the thing inside is also repeated. - Overlapping alternation inside a quantifier:
(a|ab)+— when two branches of an alternation can both match the same characters, the engine has multiple ways to divide up the same input. - Unanchored
.*or.+next to another greedy quantifier, particularly when both are hunting for the same kind of character.
If you're not sure whether a pattern you're reviewing has this shape, the Regex Visualizer is a fast way to check — nested quantifier badges are visually obvious once the pattern is broken into a diagram instead of a flat string.
How to avoid it
- Don't nest quantifiers over the same characters. Rewrite
(a+)+as justa+— in the vast majority of real patterns, the outer quantifier is redundant once you actually think about what it's trying to express. - Make alternation branches mutually exclusive. If two options in a
|can match the same text, the engine has to explore both. Restructure them so each branch owns a distinct prefix where possible. - Prefer specific character classes over
.. Matching[^"]*inside quotes is both more correct and faster than.*, because a negated class can't accidentally overlap with a neighboring quantifier the way a wildcard can. - Anchor when you mean to.
^and$let the engine reject a non-matching string immediately instead of trying every starting position in it. - Test with adversarial input, not just the happy path. A long string of the "almost matches" shape — lots of valid-looking characters followed by one invalid one at the very end — is exactly what triggers worst-case backtracking. Throw one at any pattern that validates user input before you trust it in production.
Smaller wins that add up
- Compile a regex literal once, not on every call.
/foo/.test(x)inside a hot loop is fine — JavaScript engines cache literal regex objects — butnew RegExp(pattern)built from a template string every iteration re-parses the pattern every single time. Hoist it outside the loop. - Use non-capturing groups when you don't need the capture.
(?:...)instead of(...)avoids the (small, but free) overhead of tracking a capture you're going to discard, and it also makes the pattern's intent clearer to the next reader. - Lazy quantifiers aren't automatically faster.
<.+?>vs<[^>]+>often match the same strings, but the negated character class version doesn't need to backtrack character-by-character to find the boundary — it just stops at the first>. When you know what character ends a run, a negated class is usually both clearer and faster than a lazy wildcard. - The sticky flag (
y) helps tokenizers. When you're scanning through a string piece by piece withlastIndex,yforces the match to start exactly atlastIndexinstead of scanning forward to find the next possible match — which is what you want for a tokenizer and avoids redundant scanning.
Measuring instead of guessing
Intuition about regex performance is unreliable — the only way to know a pattern is safe is to time it against a deliberately adversarial input. A quick way to sanity-check:
const pattern = /^(a+)+$/;
const input = "a".repeat(30) + "!";
console.time("regex");
pattern.test(input);
console.timeEnd("regex");If that takes milliseconds, you're fine. If it doesn't return, you've found a problem worth fixing before it ships.
Try it yourself
Paste a pattern into the Regex Visualizer to check its structure for risky nesting, use the Regex Tester to try it against real and adversarial input, and keep the Regex Cheat Sheet Generator handy for the syntax itself. All three run entirely in your browser.