Here's a signup check. A display name is words with single spaces between them.

const NAME_RE = /^(\w+\s?)*$/; 

"Ada Lovelace" passes. "Linus" passes. Each request takes about 3 ms.

Then someone signs up as aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!. That's 30 a's and an exclamation mark.

The server takes 5.7 seconds to say "invalid name". And for those 5.7 seconds, it doesn't answer anyone else.

The test

A plain Node server:

import http from "node:http"; const NAME_RE = /^(\w+\s?)*$/; http.createServer((req, res) => { const url = new URL(req.url, "http://localhost"); if (url.pathname === "/health") return res.end("ok"); const name = url.searchParams.get("name") ?? ""; res.end(NAME_RE.test(name) ? "welcome" : "invalid name"); }).listen(3000); 

And a client in a separate process that hits /health every half second, like a load balancer would, then sends the bad name:

 1.5s /health answered in 3 ms 2.0s /signup?name=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa! 7.7s -> "invalid name" after 5696 ms 2.5s /health answered in 5200 ms 3.0s /health answered in 4698 ms 3.5s /health answered in 4197 ms ... 7.0s /health answered in 698 ms 7.5s /health answered in 198 ms 8.0s /health answered in 5 ms 

Every health check sent during those seconds just waited. Node runs your JavaScript on one thread, and a regex match doesn't stop halfway to let other code run. No await, no break. One request, 31 bytes, and the whole process is stuck.

Every extra character doubles it

Same regex, on Node 24, this machine:

Input Time
21 characters 5.6 ms
25 characters 90 ms
27 characters 352 ms
29 characters 1.4 s
31 characters 5.7 s

Two more characters, four times slower. At 41 characters it would be about an hour and a half.

Step counter for the name regex. As the input grows from 1 to 30 a's, the backtracking engine goes from a few steps to over 4 billion, while a linear engine stays near 300

Why

JavaScript's regex engine backtracks. When there's more than one way to match something, it picks one. If that fails later, it goes back and tries the next one.

(\w+\s?)* means "one or more words, each with an optional space after it". The space is optional, so aaaa can be one word. Or aaa and a. Or aa and aa. Or a, a, a, a. Every gap between two letters is either a word break or not. 30 letters have 29 gaps, so that's 2^29 ways. Over 500 million.

On a normal name, the first way works and the engine stops right there. But with the ! at the end, the $ fails every time. The engine can only say "no match" after it has tried every single split.

Here it is with 4 a's. The widget uses (a+)+, which is the same shape without the space:

Every way to split 4 a's. The engine tries 8 splits one after another, and the end-of-text check fails on the exclamation mark every time

8 ways for 4 a's. 16 for 5. One more letter, twice the work.

The shape to look out for: a repeated group, with something inside it that also repeats, so the same text can be split more than one way. (a+)+, (\w+\s?)*, (a|aa)+ are all the same problem.

The fix

Write it so each character can only be matched one way:

const NAME_RE = /^(\w+\s)*\w*$/; 

Now every repeated word has to end with a space. A run of letters can only be read one way. The same 31 characters: 0.003 ms.

Does it accept exactly the same names? I tested every string up to 9 characters made from a, b, space, tab and !. That's 2.4 million strings, and the two regexes agreed on all of them.

Other things that help:

  • Check the length first. A display name doesn't need to be 500 characters. That won't save you here, since 31 is short. But most ReDoS bugs in real code grow slower (n² instead of 2ⁿ), and for those a length limit is enough.
  • Let a linter catch it. eslint-plugin-regexp has a rule called no-super-linear-backtracking that flags these patterns before they ship.
  • Use an engine that doesn't backtrack, especially if users can type their own patterns. Go's regexp and the re2 package on npm never backtrack. V8 has one too, behind a flag: with node --enable-experimental-regexp-engine-on-excessive-backtracks, the same 31 characters took 1.2 ms. It's experimental and off by default, so I wouldn't build on it.

It has happened to big sites

Stack Overflow was down for about 34 minutes in July 2016. A regex that trimmed whitespace hit a post with around 20,000 spaces in a row. Cloudflare had a 27-minute outage in July 2019 because of one regex in a firewall rule. Both wrote public postmortems, and both are worth reading.

One weird thing

The first time I ran this, the 31 characters took 41 seconds, not 5.7.

V8 runs a new regex through a slower interpreter first, and only compiles it to machine code once it's been used. My first test sent the bad name before any normal one, so it ran cold. With 27 characters: 2.4 s cold, 0.35 s warm. So a server that just restarted is the easiest one to freeze.

The thing to remember

If a regex runs on input you don't control, ask: can the same characters be matched in more than one way? If yes, and something after it can fail, one short string can stop your server.

You can drag the input size yourself and watch the step count blow up: https://software-atlas.haiduc0147.dev/t/regular-expressions#catastrophic