RegexCalc Regex Engine

The Empty Match That Never Stops: Zero-Width Results in JS Regex Loops

RegexCalc — Clean, Modern Regular Expression Calculator & Tester Guides · Updated 2026-10-13 · All guides

A regex that matches nothing is the one that will hang your server. Empty matches are legal, they are useful, and they do not advance the cursor — so every loop you have ever written over exec assumes something the engine does not guarantee.

The runaway, measured line by line

Pattern /a*/g, input "bbb". The pattern matches zero characters everywhere, because * allows none. Here is what a bare exec loop sees, printed from the actual object:

exec#1  matched=""  len=0  lastIndex=0
exec#2  matched=""  len=0  lastIndex=0
exec#3  matched=""  len=0  lastIndex=0
exec#4  matched=""  len=0  lastIndex=0

lastIndex never moves. exec returns a truthy result forever. My own unguarded reproduction was still going when I capped it at 5 iterations — with lastIndex still exactly 0. This is not slow code; it is a loop with no exit condition, and the while ((m = re.exec(s)) !== null) shape that copied straight out of a tutorial.

/^/gm on "a\na" behaves identically: every iteration reports len=0, lastIndex=0. Any pattern that can match the empty string — x*, x?, \b*, (?:), (?=x)?, a lookahead-only pattern — has this property at some position.

Why lastIndex is the whole story

exec resumes from lastIndex when the regex is global. A zero-length match leaves it where it was, so the next exec starts at the same offset, matches the same nothing, and the state machine has made no progress. The fix is not to detect "we matched nothing" — that is a legitimate result you may want to keep. The fix is to force progress:

const results = [];
let m;
while ((m = re.exec(s)) !== null) {
  results.push(m);
  if (m.index === re.lastIndex) re.lastIndex++; // the guard
}

Measured with the guard: /a*/g on "bbb" yields matches at indices [0, 1, 2, 3] and terminates. Four results, four positions, no spin. Note the last one is index 3 — one past the end — which is correct: the empty match after the final character is real.

matchAll does not hang

"bbb".matchAll(/a*/g) returns four entries, [[0,0],[1,0],[2,0],[3,0]] (index, match length) — the same four positions, terminating normally. It cannot hang because the iterator copies the regex and tracks position itself rather than trusting the shared lastIndex your loop would corrupt. It does require the g flag: without it, matchAll throws TypeError.

Two matchAll details people get wrong, both measured:

  • It honours a hand-set lastIndex on the source regex. /a/g with lastIndex = 1 over "aab" returns [1], not [0, 1].
  • It does not consume that lastIndex. After matchAll finished, the original regex still reported lastIndex = 1. If you then call .exec() on that same object, you resume where you left it.

So prefer matchAll in new code, and keep the guard when you need exec's lastIndex for stateful scanning.

split does the opposite of what you expect

If zero-width matches make loops explode, you would expect split to explode into empty strings. It does not:

CallResult
"abc".split(/b*/)["a", "c"]
"abc".split(/x*/) (never matches)["a", "b", "c"]
"abc".split(/b/)["a", "c"]
"aaa".split(/a*/)["", ""]
"ab".split(/()/)["a", "", "b"]
"ab".split(/(?:)/)["a", "b"]

The rule the engine actually applies: a split at the start or end of the string does not produce a leading/trailing empty piece, and an empty match that falls inside the previous match is ignored. /(?:)/ is the empty alternation — it matches everywhere — yet "ab" still gives two pieces, not three.

/()/ is the one that bites in production: a capturing group around nothing captures the empty string, and String.prototype.split splices captures into the output. That is how ["a", "b"] quietly becomes ["a", "", "b"] and your column count is off by one. Same reason "a1b".split(/(\d)/) returns ["a","1","b"] — useful when you want it, catastrophic when you forgot the group. If your separator regex has any group, make it (?:…).

Sticky (y) has the same trap

/a*/y never advances on its own either, and it is worse in one way: with y a non-match at the current position returns null immediately, so a stuck zero-width match makes the loop stop early instead of spinning. Both failure modes come from the same missing progress step, so guard it the same way — advance by one when the match was empty:

let i = 0; const hits = [];
let m;
while (i <= s.length) {
  re.lastIndex = i;
  m = re.exec(s);           // re has /y
  if (!m) { i++; continue; }
  hits.push(m.index);
  i = m.index === re.lastIndex ? re.lastIndex + 1 : re.lastIndex;
}

Measured on "bbb" with /a*/y: [0, 1, 2, 3], terminating.

Do this

  1. Default to matchAll for global scans. It is not just shorter, it owns the cursor so the empty-match trap cannot apply.
  2. If you use exec, add the one-line if (m.index === re.lastIndex) re.lastIndex++. It costs nothing when matches are non-empty.
  3. Give every scan loop an iteration ceiling during development so a bug is a thrown error, not a hung process.
  4. Write (?:…) for separator groups in split unless you want the captures.
  5. Ask yourself whether the pattern can match empty. *, ?, {0,n}, empty alternation, and pure lookaheads all can.

Related: regex gotchas, the lookaround guide, $ and . across engines.


All output above was produced by throwaway probes on this machine on 2026-10-13 (Node v24.21.0). The runaway loop was killed by an explicit iteration cap rather than allowed to run; I did not measure an uncapped loop's wall-clock cost, so no timing claim is made for it.

Developer Sponsor / Partner
Copied to clipboard!