skip to content

A module declares `const RE = /^\d+$/g;` and a validate(value) function that returns RE.test(value). Callers report that the same valid value passes on one call and fails on the next. Why, and how do you fix it?

level: middleimportance: must knowfreq 58%

answer

  1. regex objects carry mutable state
  2. a cursor that survives the call
  3. g and y make it consult that cursor
  4. only test and exec advance it
  5. string-side methods reset or clone

basics

~20 s

A regex with the g flag carries mutable state: test and exec resume from its lastIndex property and advance it after each match, so a shared regex object alternates between matching and failing. Drop the g flag for a pure test, reset RE.lastIndex = 0, or build a fresh regex per call.

solid answer

~50 s

A `RegExp` object with the `g` (or `y`) flag is **stateful**. Its `lastIndex` property tells `test` and `exec` where to start searching, and both methods write it back to the end of the match on success. So the first `RE.test('123')` matches, sets `lastIndex` to 3, and returns `true`; the second call starts at index 3, finds nothing, returns `false` — and resets `lastIndex` to 0, which is why the results alternate rather than failing permanently. It is not a threading issue: it is one shared mutable object. Three fixes, best first: drop the `g` flag, since a pure yes/no test never needs it; or set `RE.lastIndex = 0` before every use; or construct the regex inside the function so each call gets a fresh object. Note that `String.prototype.match`, `matchAll`, `search`, `split` and `replace` are all immune — they reset, save or clone `lastIndex` for you.

code

javascript · 9 lines
javascript
const BAD = /^\d+$/g;
console.log(BAD.test('123'), BAD.lastIndex); // true 3
console.log(BAD.test('123'), BAD.lastIndex); // false 0
console.log(BAD.test('123'), BAD.lastIndex); // true 3

const GOOD = /^\d+$/;
console.log(GOOD.test('123')); // true
console.log(GOOD.test('123')); // true
console.log(GOOD.lastIndex);   // 0 — never touched

go deeper

for a junior

Know that a regex with the g flag remembers where it stopped, so calling test() twice on the same string can give different answers. Removing g from a plain test is the fix.

for a middle

Explain the mechanism: lastIndex is a writable property that exec and test read and then advance on success and zero on failure, and it is consulted only when g or y is set.

for a senior

Diagnose it from the symptom — intermittent, alternating validation failures that depend on prior input lengths — and know which string-side methods are immune because they reset, save or clone lastIndex.

for a principal

Make it unreviewable-past: a lint rule or convention that a g-flagged regex is never used with test, and that shared regex constants are stateless by construction rather than by caller discipline.

## lastIndex is real, mutable, per-object state Every `RegExp` instance owns a writable `lastIndex` property, initially `0`. It is not a hidden implementation detail — you can read it, write it, and log it: ```js const RE = /^\d+$/g; RE.test('123'); // true RE.lastIndex; // 3 RE.test('123'); // false RE.lastIndex; // 0 RE.test('123'); // true again ``` That alternation is the entire bug. It is deterministic, which is why it reproduces on every second call and confuses people looking for a race condition. ## Which methods read and write it `lastIndex` is consulted **only** when the regex carries `g` or `y`, and only by two methods: - `RegExp.prototype.exec` — starts at `lastIndex`; on success sets `lastIndex` to the index just past the match; on failure sets it back to `0`. - `RegExp.prototype.test` — specified in terms of `exec`, so it does exactly the same. Without `g` and without `y`, both methods always start at index 0 and never write `lastIndex`. That is why `/^\d+$/.test(x)` is a pure function of `x` and `/^\d+$/g.test(x)` is not. The `y` (sticky) flag makes the dependency even stronger: the match must begin *exactly* at `lastIndex` rather than anywhere at or after it, so a stale value doesn't merely skip text, it makes the match fail outright. ## Which methods are immune The string-side APIs were specified so that this state never leaks into them: - `str.match(re)` with `g` sets `lastIndex` to 0 before it starts. - `str.matchAll(re)` operates on an internal **clone** of the regex, leaving yours untouched. - `str.search(re)` saves `lastIndex`, searches from 0, and restores the saved value. - `str.split(re)` uses an internal sticky clone. - `str.replace` / `replaceAll` set `lastIndex` to 0 first when the regex is global. So the exposure is precisely `test` and `exec` on a `g`/`y` regex you kept a reference to. ## Why the shared-constant pattern is so tempting Hoisting a regex to module scope is normally good practice: it compiles the pattern once instead of on every call, and it names the rule in one place. The trap is that hoisting a *stateless-looking* value silently shares *state* the moment the pattern carries `g`. And `g` gets added for all sorts of reasons — copied from a snippet, left over from a `replace` call site, or added "because it seemed more thorough". The symptom is nasty in production: intermittent validation failures that depend on how many times the function has been called and on the *lengths* of previous inputs. It also crosses request boundaries in a server process, because module state outlives a request. Two different callers effectively share the cursor. ## The three fixes, ranked **1. Remove the flag.** A `test` that answers "does this string match?" wants no `g`. `const RE = /^\d+$/;` is now a genuinely pure predicate and stays hoisted. This is the right fix in the question's example — an anchored full-string pattern with `g` is meaningless. **2. Reset before use.** If some other call site genuinely needs the global flag on the same object, write `RE.lastIndex = 0;` immediately before each `test`/`exec`. It works, but it is a discipline every future caller must remember, so it is a distant second. **3. Build a fresh regex per call.** `const re = /^\d+$/g;` inside the function gives each invocation its own object and its own `lastIndex`. A regex literal in a hot path is cheap in modern engines, but you have given up the single named definition. A fourth option, when you want all matches, is to stop hand-rolling the loop: `[...str.matchAll(RE)]` gives every match without touching `RE.lastIndex` at all. ## Spotting it in review Two review heuristics catch essentially all instances. First: **a `g` flag on a regex used with `test` is almost always a bug** — `test` returns a boolean, so "find all of them" adds nothing. Second: **any regex stored beyond one call plus `exec`** needs an explicit answer to "who resets `lastIndex`?". If the answer is "nobody", it is the same bug wearing a different hat.

  • Would the same code misbehave if the flag were y instead of g?
    Yes, and more sharply. The sticky flag also makes `test` and `exec` consult and update `lastIndex`, but it additionally requires the match to begin exactly at that index rather than anywhere after it. So a stale `lastIndex` does not just skip text — it turns an otherwise valid match into a failure. The same three fixes apply.
  • Does calling str.match(RE) on the shared global regex leave the same landmine for the next caller?
    No. The global form of `match` sets the regex's `lastIndex` to 0 before it starts and leaves it at 0 when it finishes, so it neither depends on nor bequeaths a cursor position. `matchAll` goes further and works on a clone. The exposure is specific to `test` and `exec` called directly on a `g` or `y` regex.
  • Is this a concurrency bug that only shows up under load?
    No — JavaScript's run-to-completion semantics mean no two calls interleave mid-execution. It is plain shared mutable state: call one leaves a cursor behind, call two reads it. It looks load-dependent only because more traffic means more calls, and because the failing input depends on how long previous inputs were.

saying these in an interview costs you the question

  • Blames a race condition or thread safety
  • Thinks a regex literal is immutable and stateless
  • Adds the g flag to test() to be 'thorough'
  • Believes only exec advances lastIndex, not test
  • Fixes it by wrapping the call in try/catch

context