skip to content

Two variants of the loop `while ((m = re.exec(s)) !== null) { … }` hang forever: one where `re` is `/\d/` and one where `re` is `/\d*/g`. Explain each hang, and say what you would write instead.

level: seniorimportance: should knowfreq 38%

answer

  1. the loop needs the cursor to move
  2. no flag means no cursor at all
  3. an empty match ends where it started
  4. bump lastIndex by hand, or
  5. let the built-in iterator do it

basics

~20 s

Without the g flag, exec always restarts at index 0 and returns the same first match forever. With g but a pattern that can match the empty string, a zero-length match leaves lastIndex unchanged, so the loop re-matches at the same position. Prefer str.matchAll(re), which handles both.

solid answer

~50 s

An `exec` loop only terminates because `lastIndex` advances, and both variants break that. `/\d/` has no `g` flag, so `exec` ignores and never writes `lastIndex`: every iteration searches from position 0 and returns the same match, and the loop never sees `null`. `/\d*/g` does advance `lastIndex` — to the end of the match — but `\d*` can match the empty string, and for a zero-length match the end index equals the start index, so `lastIndex` does not move and the same empty match repeats forever. The classic manual fix is `if (m[0] === '') re.lastIndex++;` inside the loop body, plus making sure the flag is present. The better answer is not to hand-roll the loop at all: `for (const m of s.matchAll(re))` gives the same rich match objects, requires the `g` flag so the first bug is impossible, advances past zero-length matches itself, and works on a clone so the caller's regex is left alone.

code

javascript · 16 lines
javascript
const s = 'a1b22c';

// Safe manual loop: g flag present, zero-length matches guarded.
const re = /\d*/g;
let m;
while ((m = re.exec(s)) !== null) {
  if (m.index === re.lastIndex) re.lastIndex++; // nothing consumed
  if (m[0] !== '') console.log(m[0], m.index);
}
// '1' 1
// '22' 3

// Preferred: no cursor to manage, empty matches handled for you.
for (const hit of s.matchAll(/\d+/g)) {
  console.log(hit[0], hit.index);
}

go deeper

for a junior

Know that an exec loop needs the g flag to make progress, and that String.prototype.matchAll is the modern way to iterate every match without managing that yourself.

for a middle

Explain that exec starts at lastIndex and writes it to the match's end index, and that a zero-length match writes back the same value, which is why the cursor stalls.

for a senior

Diagnose the hang from its signature — pegged CPU, no exception — separate it from a pathological-backtracking stall inside a single call, and justify matchAll as the fix rather than a hand-written guard.

for a principal

Decide the codebase rule: iteration goes through matchAll, and a manual exec loop needs an explicit reason such as a sticky tokeniser, documented at the call site.

## Why an exec loop terminates at all `RegExp.prototype.exec(str)` on a regex carrying `g` or `y` does three things: it starts searching at the regex's `lastIndex`; on success it returns a match object and sets `lastIndex` to the index one past the end of the match; on failure it returns `null` and resets `lastIndex` to 0. The `while ((m = re.exec(s)) !== null)` idiom relies entirely on step two moving the cursor forward until step three fires. Remove that guarantee and the loop is infinite. ## Hang one: the missing flag ```js const re = /\d/; // no g let m; while ((m = re.exec('a1b2')) !== null) { console.log(m[0]); // '1' forever } ``` Without `g` (and without `y`), `exec` neither reads nor writes `lastIndex` — it always searches from index 0. So it returns the same first match on every iteration and never returns `null`. Notice the failure mode: the loop does not merely produce wrong results, it never ends, and it usually pegs a CPU rather than throwing anything you can catch. A closely related variant puts the literal in the condition itself: ```js while ((m = /\d/g.exec(s)) !== null) { … } ``` Here the `g` flag is present, but a regex **literal creates a fresh object each time it is evaluated**, so each iteration gets a brand-new regex with `lastIndex === 0`. Same hang, different cause. Hoist the regex into a variable outside the loop. ## Hang two: the zero-length match ```js const re = /\d*/g; // * allows the empty match let m; while ((m = re.exec('ab')) !== null) { console.log(JSON.stringify(m[0])); // '""' forever } ``` The flag is there and `exec` does write `lastIndex` — it sets it to the end index of the match. But `\d*` matches the empty string at position 0, so the start and end indices are both 0, and `lastIndex` is written back as 0. Nothing moved. Any pattern that can match nothing carries this risk: `\d*`, `\s*`, `(?:abc)?`, an alternation with an empty branch, or a pattern made of nothing but lookarounds and anchors. The standard guard is to bump the cursor yourself when the match is empty: ```js while ((m = re.exec(s)) !== null) { if (m[0] === '') re.lastIndex++; // or: if (m.index === re.lastIndex) // …handle m } ``` That is exactly what the spec's own `AdvanceStringIndex` step does inside the iterating APIs — and doing it by hand on a UTF-16 string means you can split a surrogate pair, another reason to prefer the built-in. ## What to write instead ```js for (const m of s.matchAll(re)) { console.log(m[0], m.index, m[1]); } ``` `String.prototype.matchAll` (ES2020) removes every failure mode above: - It **throws a `TypeError` if the regex is not global**, so the missing-flag hang becomes an immediate, obvious error instead of a spin. - It **advances past zero-length matches** internally, so the second hang cannot occur. - It **operates on a clone** of the regex seeded with your `lastIndex`, so it does not leave your (possibly module-level, possibly shared) regex object with a cursor parked mid-string. - It yields the **same rich match objects** `exec` produces — `m[0]`, the captures, `m.index`, `m.input` — so nothing is lost relative to the manual loop. - It is **lazy**, so `break` in the `for…of` stops the work, which a `str.match(re)` with `g` cannot do (and which would lose the captures and indices anyway). Spread it — `[...s.matchAll(re)]` — when you want an array, remembering that the iterator is one-shot. ## When a manual exec loop is still defensible Two cases. First, a **sticky** (`y`) tokeniser, where you deliberately drive `lastIndex` yourself: you set it to the current position, require the match to start exactly there, and advance by hand — the state is the point, not an accident. Second, a hot parsing loop where you have measured that allocating a match object per iteration matters. In both cases the code should say so, because a bare `while (exec)` reads as legacy. ## Diagnosing it in the wild A hung request with one core at 100% and no exception is the signature. Take a CPU profile or pause in the debugger: the stack sits inside the regex loop. Then check two things in order — does the regex have `g`, and can the pattern match the empty string? Note that this is a *loop* bug, distinct from a pathological-backtracking hang, where a single `exec` call never returns; in this one each `exec` call finishes instantly and it is the loop that never ends.

  • How do you detect a zero-length match inside the loop without inspecting m[0]?
    Compare positions: after a successful `exec`, `re.lastIndex` equals the end index of the match, so `m.index === re.lastIndex` means the match consumed nothing. That test is equivalent to `m[0] === ''` and is the form the spec's own advancing step uses. Either way, increment `re.lastIndex` before continuing.
  • Why does putting the regex literal in the loop condition hang even with the g flag present?
    A regex literal evaluates to a new `RegExp` object every time control reaches it, and a fresh object has `lastIndex === 0`. So each iteration searches from the start of the string and returns the same first match, exactly like the missing-flag case. Hoist the regex into a `const` outside the loop.
  • Is a hang inside a single exec call the same bug?
    No. If one `exec` call never returns, the loop is irrelevant — that is pathological backtracking inside the matching engine, driven by the pattern's structure and the input, and the fix is to rewrite the pattern. The loop bug is the opposite shape: every `exec` returns promptly and it is the surrounding `while` that never terminates.

saying these in an interview costs you the question

  • Assumes exec always advances, flag or not
  • Thinks a zero-length match cannot happen
  • Puts the regex literal inside the loop condition
  • Confuses the loop hang with catastrophic backtracking
  • Fixes it by resetting lastIndex to 0 inside the loop

context