If your page lets people type a regular expression and run it, one bad pattern can lock the whole tab. JavaScript's RegExp engine backtracks, so a pattern like this one
/^(a+)+$/
against "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!" takes exponential time. The browser's main thread is busy the whole time, so clicks, scrolling and even the "Stop" button stop responding. There is no way to interrupt a running RegExp.exec() from the same thread.
I ran into this while building the regex tester on A2Z Tools. The fix turned out to be small: run the match in a Web Worker and kill the worker if it takes too long.
1. Put the matcher in a worker built from a Blob
You don't need a separate file. Build the worker source as a string and load it through a Blob URL:
const WORKER_SOURCE = `
self.onmessage = (e) => {
const { pattern, flags, text } = e.data;
try {
const re = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g');
const matches = [];
let m, count = 0;
while ((m = re.exec(text)) !== null && count < 5000) {
matches.push({ index: m.index, match: m[0], groups: m.slice(1) });
count++;
if (m[0].length === 0) re.lastIndex++; // avoid an infinite loop on empty matches
}
self.postMessage({ ok: true, matches, truncated: count >= 5000 });
} catch (err) {
self.postMessage({ ok: false, error: err.message });
}
};`;
Two details matter here:
-
Empty matches. A pattern such as
/a*/gcan match an empty string. Without thelastIndex++,exec()returns the same empty match forever. -
A match cap. Even a fast pattern can return millions of matches. Capping at 5,000 keeps the
postMessagepayload sane.
2. Give it a time budget and terminate on timeout
worker.terminate() is the one thing that can stop a runaway regex, because it kills the worker's thread no matter what it is doing.
const TIME_BUDGET_MS = 1500;
function runRegex(pattern, flags, text) {
return new Promise((resolve) => {
const url = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: 'application/javascript' }));
const worker = new Worker(url);
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
worker.terminate();
URL.revokeObjectURL(url);
resolve(result);
};
const timer = setTimeout(
() => finish({ ok: false, timedOut: true }),
TIME_BUDGET_MS
);
worker.onmessage = (e) => finish(e.data);
worker.onerror = (e) => finish({ ok: false, error: e.message });
worker.postMessage({ pattern, flags, text });
});
}
The settled flag is there because the timeout and the worker's reply can race. Whichever fires first wins, and the other is ignored.
A new worker per run sounds wasteful, but creating one takes a few milliseconds. It also means a terminated worker never leaves bad state behind for the next run.
3. Validate input before you start the worker
Cheap checks up front save a worker round trip and give clearer errors:
if (pattern.length > 500) return error('Pattern is too long');
if (text.length > 200_000) return error('Test text is too long');
if (!/^[gimsuy]*$/.test(flags)) return error('Flags may only contain g, i, m, s, u, y');
4. Be honest about what a timeout means
When the budget runs out, the UI says the pattern may be vulnerable to catastrophic backtracking on this input. A timeout is not proof that a pattern is unsafe, and finishing in time is not proof that it is safe. A different input could still blow up. If you need a real guarantee for server-side code, use a non-backtracking engine such as RE2. Or check for nested quantifiers like (a+)+ and (a|a)*, which are the usual culprits.
Try it
You can paste ^(a+)+$ and a long run of as followed by ! into the regex tester and watch it time out after 1.5 s while the page stays responsive. The same pattern in a plain <script> would hang the tab.
Disclosure: I'm the founder of A2Z Tools, where this tester lives. This post was drafted with AI assistance; the code is taken from the production tool.
Top comments (0)