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
.* 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']
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']
(?!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']
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'
$<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`);
}
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
(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)