DEV Community

alapha888
alapha888

Posted on

JavaScript regex in practice: greedy vs lazy, lookarounds, and the 300 ms pattern

Five regex behaviours that cause most of the "it matched too much / too little / hung the page" bugs I see, each with a runnable Node example (tested on Node 24).

1. Greedy .* eats across boundaries

const s = '<note>first</note> and <note>second</note>';
s.match(/<note>.*<\/note>/)[0];
// '<note>first</note> and <note>second</note>'   ← one giant match
Enter fullscreen mode Exit fullscreen mode

.* grabs as much as it can, then backtracks only until </note> matches — the last one.

Fix A — lazy: /<note>.*?<\/note>/g → ['<note>first</note>', '<note>second</note>']

Fix B — negated class (usually better): /<note>[^<]*<\/note>/g gives the same result, but [^<]* cannot cross a <, so there's nothing to backtrack into. Say what the content can't contain instead of hoping laziness stops at the right place.

(And yes: for real HTML, use a parser. This is for log lines and tiny formats.)

2. Lookbehind grabs a value without its label

'EUR 12.50, USD 7.00, total USD 19.50'.match(/(?<=USD )\d+\.\d{2}/g);
// ['7.00', '19.50']
Enter fullscreen mode Exit fullscreen mode

Lookbehind is supported in all current browsers and Node. The USD part must be there but isn't included in the match — no capture group juggling.

3. Negative lookahead to reject a prefix

const ok = /^(?!tmp(?:-|$))[a-z]+(?:-\d+)?$/;
['build-1', 'tmp-2', 'tmp', 'release'].filter(x => ok.test(x));
// ['build-1', 'release']
Enter fullscreen mode Exit fullscreen mode

(?!tmp(?:-|$)) says "the string must not start with tmp followed by - or end". Without the (?:-|$) part you'd also reject tmpl, which may or may not be what you want — write the test cases first.

4. ^ only means "line start" with the m flag

'ok\nWARN disk\nok\nWARN cpu'.match(/^WARN .*/gm);
// ['WARN disk', 'WARN cpu']
Enter fullscreen mode Exit fullscreen mode

Drop m and you get null, because ^ then means start of the whole string. Drop g and you get only the first match (plus capture info). These two flags account for a lot of "works on one line, fails on the file" reports.

5. Named groups in replacements

'Doe, Jane'.replace(/^(?<last>\w+), (?<first>\w+)$/, '$<first> $<last>');
// 'Jane Doe'
Enter fullscreen mode Exit fullscreen mode

$<name> is far easier to review than $2 $1 six months later.

Bonus: the pattern that takes 300 ms on 27 characters

for (const n of [20, 22, 24, 26]) {
  const input = 'a'.repeat(n) + '!';
  let t = performance.now(); /^(a+)+$/.test(input); const bad = performance.now() - t;
  t = performance.now(); /^a+$/.test(input); const good = performance.now() - t;
  console.log(`n=${n}  (a+)+ ${bad.toFixed(1)} ms   a+ ${good.toFixed(3)} ms`);
}
Enter fullscreen mode Exit fullscreen mode

On my machine:

n=20  (a+)+ 32.8 ms   a+ 0.019 ms
n=22  (a+)+ 19.2 ms   a+ 0.013 ms
n=24  (a+)+ 95.2 ms   a+ 0.010 ms
n=26  (a+)+ 308.6 ms   a+ 0.003 ms
Enter fullscreen mode Exit fullscreen mode

(Small-n timings are noisy because of JIT warm-up; the trend is the point.) The ! makes the match fail, and a nested quantifier (a+)+ lets the engine try every way of splitting the a's between the inner and outer loop — roughly doubling the work per extra character. Each additional 2 characters ≈ 3–4× slower here. With user input, that's a denial-of-service bug (ReDoS).

Rules of thumb:

  • Avoid a quantified group whose contents can also match repeatedly: (a+)+, (\w+\s?)*, (.*,)*.
  • Make alternatives mutually exclusive: (a|aa)* is a trap (many ways to split the same run of a's); a* is not.
  • Prefer negated classes ([^,]*) over .*? between delimiters.
  • Test the failing case with a long input, not just the happy path.

If you want structured practice after this, I made 12 offline JavaScript regex exercises with 90 fixtures and a local HTML checker that compares exact matches, capture groups and replacement output, plus printable cards: Offline Regex Test Cards (pay what you want, from $5). Everything above is free to copy.

Top comments (0)