Use a regular expression safely by building it from small, tested pieces, anchoring it to the part of the text you actually mean, and never letting a quantifier sit inside another quantifier over the same characters. A pattern such as /^(a+)+$/ looks harmless and matches a simple idea — one or more groups of as — yet on a string of 28 as followed by a ! it took 2,115 milliseconds to answer "no match" in our test run, while the rewritten pattern /^a+$/ answered the same question in 0.08 milliseconds, and in 0.45 milliseconds on a string of 100,000 characters. That gap is catastrophic backtracking, and it is the main way a regex stops being a convenience and becomes a denial-of-service risk. Our Regex Tester runs patterns in a background worker with a one-second timeout, so that a bad pattern cannot freeze the page — the rule this guide teaches you to design by.

Tokens: the building blocks

A pattern is a sequence of tokens. Each token matches a character, a class of characters, a position, or a repetition of the token before it.

Token Meaning Tested behaviour
\d, \w, \s A digit, a word character (letter, digit, underscore), whitespace \b\d{3}\b on Codes 123, 1234 and 45 matched only 123, at index 6 — 1234 failed because there is no word boundary after three digits inside a longer number
[abc], [^abc], [a-z] One character from a set, not from a set, or in a range Sets are how you say "a vowel" or "not a quote" without listing alternatives
. Any character except a line terminator The dot is greedy by default when quantified — see the greedy/lazy test below
^, $ Start and end of the text (or of a line, with the m flag) Anchors decide whether a match may start anywhere or only at the edges
\b A word boundary — between a word character and a non-word character It matches a position, not a character, so it consumes nothing
*, +, ?, {n}, {n,m} Repeat the previous token: zero or more, one or more, optional, exactly n, between n and m These are the tokens that make backtracking possible, and dangerous when nested
a\|b Alternation: match a or b The engine tries alternatives from left to right
\., \/, \\ Escaped literals: a real dot, slash or backslash Escape a metacharacter when you mean the character itself

Flags sit after the closing slash and change the whole pattern: g finds every match instead of stopping at the first, i makes matching case-insensitive, m lets ^ and $ work per line, s lets . cross line breaks, and u enables full Unicode handling. In our run, /london/gi against London, LONDON, london returned three matches at indexes 0, 8 and 16 — without i it would have returned one, and without g the search would have stopped at the first.

Groups: capturing, naming and not capturing

Parentheses group tokens so a quantifier or alternation applies to the whole group — and, by default, they also capture the matched text for later use. Running /(\d{4})-(\d{2})-(\d{2})/ over Order on 2024-03-02, due 2024-03-09 produced two matches, each with three captured groups: the first match, at index 9, captured 2024, 03 and 02. Named groups make the intent readable: /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/ on 2024-03-02 captured the same three values under the names year, month and day, and a replace using those names turned the date into 02/03/2024 with the replacement string $<day>/$<month>/$<year>.

When a group exists only to group, make it non-capturing with (?:…). In /(?:Mr|Ms|Dr)\s+(\w+)/g run over Dr Patel and Ms Lee, the matches were Dr Patel (index 0) and Ms Lee (index 13), and the only captured group in each was the surname — Patel, then Lee. The title alternation did its grouping job without occupying a group number. Two more group behaviours are worth knowing: a backreference such as \1 matches the same text an earlier group captured — /\b(\w+)\s+\1\b/i matched the the in the the report, which is the classic doubled-word check — and a lazy quantifier changes how much a group takes: /<.+>/ on <b>bold</b> matched the whole string <b>bold</b> in one match, while /<.+?>/ matched <b> and </b> as two separate matches.

Catastrophic backtracking, measured

Backtracking is the engine's normal strategy: when a pattern can match the same text in several ways, the engine tries one way, and if the rest of the pattern fails, it backs up and tries another. The danger appears when a quantified group contains a quantifier over the same characters — (a+)+, (\w+\s?)*, (.+)+ — because the number of ways to divide the text doubles, roughly, with every extra character. On a matching string the engine finds a division quickly. On a string that almost matches, it must try every division before it can say no.

We measured exactly that. Note on method: these timings come from a small standalone Node harness written for this test — not from the Regex Tester page — with the pattern run directly, timed with a high-resolution timer, and, for the timeout test, run in a separate process that was killed at 1,000 ms. The pattern and strings are real; the timings below are the measured outputs on this machine, and absolute numbers will differ on yours — the growth pattern will not.

Input (a repeated n times, then !) /^(a+)+$/ result Time
n = 10 No match 0.11 ms
n = 15 No match 0.36 ms
n = 20 No match 8.12 ms
n = 22 No match 32.12 ms
n = 24 No match 140.31 ms
n = 26 No match 534.67 ms
n = 28 No match 2,115.69 ms

From n = 20 onwards, each two extra characters multiply the time by roughly four — the time doubles per character. The same pattern on 28 as with no trailing ! returned a match instantly (0 ms at the timer's resolution), which is what makes this bug so slippery: your tests, which use matching inputs, all pass quickly, and the first long non-matching input in production is the one that hangs. When we ran the n = 30 case in a child process with a 1,000 ms limit, the harness killed the process at 1,005 ms with no result returned — the pattern was still working when the timeout cut it off. That is the behaviour you want from any tool or service that runs patterns it did not write: a bounded wait and a clear "timed out", never an unbounded hang.

The safe rewrite removes the nesting. /^a+$/ expresses the same language as /^(a+)+$/ — a run of as and nothing else — with one quantifier, so there is only one way to divide the text and nothing to explode. It answered the n = 28 non-matching case in 0.08 ms, the n = 1,000 case in 0.06 ms, and n = 100,000 in 0.45 ms. When you see (X+)+, (X*)* or (X+)* in a pattern of your own, ask what the outer group adds. Usually the answer is nothing, and deleting it is the whole fix. Where a group genuinely repeats — repetitions of a word, for example — make the inner and outer tokens consume different characters, or bound the repetitions with {n,m} limits, so the divisions cannot multiply.

A safe-pattern checklist

  • Anchor deliberately. Decide whether the match must cover the whole string (^…$), a whole token (\b…\b), or may float anywhere — and write that decision into the pattern.
  • Never nest quantifiers over the same character class. One quantifier per run of characters.
  • Prefer specific classes to dots. [^"]* inside quotes beats .* because it cannot overrun the closing quote, and it cannot divide the text as many ways.
  • Bound repetitions on untrusted input. {1,64} is a design decision; + is an open promise.
  • Test with a near-miss, not only with matches. Include a long input that fails at the last character — that is the input that exposes backtracking.
  • Run untrusted patterns with a timeout, in a worker or a separate process, and treat "timed out" as a result the user sees, not a crash.
  • Do not validate complex formats with one giant pattern when a few small checks in code would be clearer. A regex that splits a value into parts, followed by ordinary checks on each part, is easier to test than a single pattern that tries to do everything — the same lesson the JSON error gallery teaches in How to Format and Validate JSON, and the reason cleaning a CSV works step by step instead of in one pass.

Frequently asked questions

What is ReDoS?

Regular expression denial of service: a pattern and an input combine to make the matching engine spend exponential time on a request, tying up a CPU that should be serving other work. OWASP documents it as an attack because the input is often supplied by a user — a form field, a search box, a log line — while the pattern was written by a developer who only ever tested matching inputs.

How do I know if my pattern can backtrack catastrophically?

Look for a quantifier inside a quantified group over overlapping characters — (a+)+, (\w+)*, (.+,)+ — or two adjacent quantifiers that can consume the same character, such as \d+\d*. Then test: run the pattern on a long string that fails at the very end and double its length a few times. If the time roughly doubles with each added character, the pattern is unsafe regardless of how fast small inputs feel.

Does a timeout fix a bad pattern?

No — it contains it. A timeout stops one bad run from hanging a page or a worker, which is why the tester enforces one, but the pattern will still time out on real input instead of answering. Rewrite the pattern; keep the timeout as the safety net for the next pattern.

Are lazy quantifiers (+?, *?) safer than greedy ones?

They change which match is found first — the shortest instead of the longest, as the <b>bold</b> example shows — but they still backtrack, and a nested lazy pattern can explode the same way. Laziness is a tool for choosing the right match, not a safety feature.

Should I use a regex to validate an email address or a URL?

Use a regex to check the broad shape and extract parts, then validate the parts with dedicated code or a parser. The patterns that try to encode the full specification in one expression are long, hard to review, and exactly the kind of nested, alternation-heavy pattern this guide warns about.

References