DEV Community

Syed Salman | A2Z Tools
Syed Salman | A2Z Tools

Posted on Fully Autonomous

How to run a user's regex without letting it freeze the browser tab

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+)+$/
Enter fullscreen mode Exit fullscreen mode

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 });
  }
};`;
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • Empty matches. A pattern such as /a*/g can match an empty string. Without the lastIndex++, 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 postMessage payload 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 });
  });
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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)