JavaScript's RegExp.test() with /g Flag: The Stateful Matcher That Skips Every Other String

2026-06-02

This function categorizes strings into those containing a digit and those without. It uses a module-level regex (because allocating one per call would be wasteful) and walks the input once.

const HAS_DIGIT = /\d/g;

function categorize(strings) {
  const withDigits = [];
  const without = [];
  for (const s of strings) {
    if (HAS_DIGIT.test(s)) {
      withDigits.push(s);
    } else {
      without.push(s);
    }
  }
  return { withDigits, without };
}

const { withDigits, without } = categorize(
  ["abc1", "def2", "ghi3", "jkl4", "mno"]
);
console.log(withDigits);  // expected: ["abc1","def2","ghi3","jkl4"]
console.log(without);     // expected: ["mno"]

Unit tests with a single string pass. Tests with a freshly constructed regex inside the loop pass. But in production, roughly half the digit-bearing strings get misfiled into without. Worse, the exact set that goes missing depends on the order of the input.

The Bug

The /g flag turns HAS_DIGIT into a stateful object. Each successful test() advances the regex's internal lastIndex property to the character after the match. The next call starts scanning from lastIndex — even if you pass a completely different string.

Walk through the input:

The function reports withDigits = ["abc1","ghi3"] and without = ["def2","jkl4","mno"]. The same regex object is being asked "where's the next match in this conceptually-continuing stream?" — except the stream changes underneath it. JavaScript shares this footgun with the global RegExp.exec() and with regexes carrying the /y (sticky) flag, but /g is by far the most common source.

Why it slips past review

The /g flag looks innocuous on test() — there's no "global" semantics in a boolean answer. Many developers add it reflexively, or copy it from a regex that was previously used with String.replace. Linters typically don't flag it. And the bug only manifests when the regex is reused — single-call usage and freshly-created regexes both work fine, which is exactly what tests tend to exercise.

The Fix

Drop the /g flag. For a yes/no membership question, you don't need it:

const HAS_DIGIT = /\d/;   // no /g

function categorize(strings) {
  const withDigits = [];
  const without = [];
  for (const s of strings) {
    (HAS_DIGIT.test(s) ? withDigits : without).push(s);
  }
  return { withDigits, without };
}

If you genuinely need /g (e.g., you're iterating matches with exec), either reset HAS_DIGIT.lastIndex = 0 before each new string, or use String.prototype.matchAll, which returns a fresh iterator and doesn't mutate the regex.

Key Takeaway: A regex with /g (or /y) is a stateful object — reusing it across test() or exec() calls makes results depend on call history, so omit the flag when you only need a boolean.

All newsletters