A /g regex makes .test() return false every other call
published
TL;DR
RegExp.prototype.test() on a regex with the g (or y) flag mutates the regex: on a match it moves lastIndex past the match, and the next call starts searching from there. Call it repeatedly on a module-level regex and you get true, false, true, false on identical input. Fix it by dropping g (a test never needs it), by using a fresh literal, or by resetting lastIndex = 0 before each call.
The problem
You hoist a regex out of a loop, because hoisting is what you do with constants:
const HAS_DIGIT = /\d/g;
const rows = ['a1', 'b2', 'c3'];
rows.filter((r) => HAS_DIGIT.test(r));
Every row contains a digit. Two come back:
[ 'a1', 'c3' ]
b2 is gone. Nothing threw, no linter complained, and the regex is correct. Remove the g and all three rows survive:
const HAS_DIGIT = /\d/;
rows.filter((r) => HAS_DIGIT.test(r)); // [ 'a1', 'b2', 'c3' ]
On a single string the pattern is even starker — same regex, same input, four calls:
const re = /\d+/g;
re.test('a1'); // true
re.test('a1'); // false
re.test('a1'); // true
re.test('a1'); // false
This is the bug’s worst property: it is data-dependent and order-dependent. A test suite that checks one string passes. A validator that runs on an odd number of inputs looks fine. The failure only shows up on the second, fourth and sixth call, which in production means “some users’ input is rejected for no reason.”
Why it happens
A regex object with g or y carries a mutable lastIndex property, and test is specified to read and write it. Watch it move:
const re = /\d+/g;
re.test('abc 42 def'); // true
re.lastIndex; // 6 <- parked after the "42"
re.test('abc 42 def'); // false — starts at index 6, finds nothing
re.lastIndex; // 0 <- a failed match resets it
That is the whole cycle. A successful match leaves lastIndex past the match; the next call resumes from there, runs off the end, returns false, and resets to 0 — which is why the pattern alternates forever rather than failing once.
The state is not an accident. It is what makes the exec loop work at all:
const re = /\d+/g;
let m;
while ((m = re.exec('1 22 333')) !== null) {
console.log(m[0], re.lastIndex); // 1 1 / 22 4 / 333 8
}
test shares that machinery — the spec defines it in terms of the same abstract match operation — so it inherits the cursor whether you wanted it or not. y (sticky) has the same state plus a stricter rule: it must match exactly at lastIndex, so a sticky regex reused across strings misfires the same way.
Which methods actually touch lastIndex
The confusing part is that most of the regex API is safe, so the habit of hoisting a /g regex is only wrong for two methods.
| Call | Reads lastIndex | Writes lastIndex | Safe to share a /g regex? |
|---|---|---|---|
re.test(s) | yes | yes | no |
re.exec(s) | yes | yes | no (that is the point) |
s.match(re) with g | no | resets to 0 | yes |
s.matchAll(re) | yes | no (works on a clone) | no |
s.replace(re, x) with g | no | resets to 0 | yes |
s.replaceAll(re, x) | no | resets to 0 | yes |
s.search(re) | no | preserved | yes |
s.split(re) | no | preserved | yes |
So "a1 b2".match(HAS_DIGIT) behaves identically every time, while HAS_DIGIT.test("a1") does not. If you have been sharing global regexes for years and only just hit this, that table is why.
matchAll is the row worth reading twice, because it is halfway between the two groups. It copies lastIndex into an internal clone and iterates that, so it starts from the stale cursor but never writes back:
const re = /\d/g;
re.lastIndex = 3;
[...'a1 b2'.matchAll(re)].map((m) => m[0]); // [ '2' ] — the '1' is skipped
re.lastIndex; // 3 — unchanged
The original regex survives untouched, which is exactly what makes this one hard to spot: the corruption came from whatever called test or exec earlier.
What to do
1. Drop the g flag. test answers “does this match anywhere”, which never needs a cursor. This is the correct fix in almost every case:
const HAS_DIGIT = /\d/;
rows.filter((r) => HAS_DIGIT.test(r)); // [ 'a1', 'b2', 'c3' ]
2. Use a literal at the call site. A regex literal creates a new object every time it is evaluated, so a literal inside a loop body or a function has no shared state to corrupt:
function hasDigit(s) {
return /\d/g.test(s); // fresh object per call — correct, but allocates
}
3. Reset before each call, when you cannot change the flags because the regex arrives from a caller or a config file:
function safeTest(re, s) {
re.lastIndex = 0;
return re.test(s);
}
4. Go through a string method that resets or ignores lastIndex:
rows.filter((r) => r.search(HAS_DIGIT) !== -1); // [ 'a1', 'b2', 'c3' ]
For user- or config-supplied patterns, option 3 is the only one that always holds, because you do not control the flags. If you want to be explicit about it, strip the flag when you build the regex:
const safe = new RegExp(input.source, input.flags.replace(/[gy]/g, ''));
Caveats
ybehaves the same and is worse. A sticky regex must match atlastIndex, so a stale non-zero cursor makes it fail even on a string that starts with a match.matchAllreads the cursor but does not clear it. A stalelastIndexsilently truncates the results, and because it writes nothing back, the next consumer sees the same stale value.matchAllthrows withoutg.TypeError: String.prototype.matchAll called with a non-global RegExp argument. So the “just dropg” advice applies totest, not to every regex you own.- Shared regexes are not only a
constproblem. A regex on a module export, in a config object, cached in aMap, or captured in a closure has exactly the same failure mode. - This is not a Node or browser quirk. It is specified behaviour and it is identical in every engine, so there is no version to upgrade past. Verified again on Node v26.2.0 while writing this.
- Concurrency makes it non-deterministic. Two async paths sharing one global regex interleave their
lastIndexwrites, which turns a reproducible every-other-call bug into a flaky one. replaceresetting to0is a courtesy, not a guarantee you should lean on. Reset explicitly if the same object is also used withtestorexec.