Notes › Tech Notes › A RegExp With the g Flag Keeps State: 4 test() Calls on One Object Pass Only 2

A RegExp With the g Flag Keeps State: 4 test() Calls on One Object Pass Only 2

Four valid strings, and two of them are rejected

Take a candidate list that should pass every check, ['123','456','789','0'], and validate it with one and the same /^\d+$/g, calling test() four times in a row. What comes back is [true,false,true,false]: every string on the list is legal, yet two of them are reported as illegal. Run the identical pattern without the g flag and the result finally reads [true,true,true,true]. Nothing about the pattern was touched between the two runs, so the pattern is not what broke.

What changed is the state hanging on the object. A RegExp built with g is stateful: every time it hits, lastIndex stops on the cell where the match ended, and the next test() reads forward from that cell. An anchored pattern has no room left after that position, so it can only answer false and reset lastIndex to 0. The reason the second call loses is written in the ending of the first one, which is exactly why the bug looks random when you only read one row of the output.

Eight APIs, and only three of them read the start position

Put the same g object at different starting positions and the read and write relationship turns out to be fully listable, which converts a vague warning about state into something you can check line by line.

CallRelation to lastIndexMeasured behaviour
test() / exec()Reads the start, writes the end back after a hitStart at 2 makes test end at 4; exec moves along with start 1, 3 and 5
matchAll()Reads the start, never writes it back to the original objectOne input yields the three slices 1+3+5, 3+5 and 5
match() / replace() / replaceAll()Ignore the start and reset it to 0 when they finishAll three starting positions give the same output
split() / search()Do not read it at allThe result has nothing to do with lastIndex

The call that shows up in almost every validation helper, test(), is precisely the member of this group that people treat as a pure function.

Six test() calls on /cat/g lastIndex end 3 11 20 0 3 11 true true true false true true

Fig. 1: the end position after six calls with one /cat/g over "cat dog cat bird cat". The fourth call answers false not because the word is missing, but because the start position has already walked past the last cat in the input.

The test page hides this trap; it bites only when you copy the code back

If all you do inside the Regex Tester is edit a pattern and watch the highlights, you will probably never meet that curve. Line 69 of the template reads new RegExp(pattern, …? flags : flags+'g'), so every keystroke builds a fresh object and quietly appends the global flag when it is missing. Leaving the flag box empty measures as g, typing i measures as gi, typing y measures as gy; typing d or v is eaten by the allowlist [^gimsuy] on line 60 and silently shrinks to a single g. The object is new on every keystroke, so lastIndex is 0 on every keystroke.

The trap really bites the moment you take that /cat/g off the page, paste it into your own validation helper and construct the object with new only once. The same reasoning explains why nothing happens when you type d in the flag box: it is not a missing browser feature, it is the allowlist. How each flag changes the range a match may cover was already written up in the earlier piece on live highlighting; this article talks only about state.

A zero-width match inside an exec loop never converges

State has a second face: the cursor may refuse to move at all. Walking /a*/g through "banana" with exec inside a while loop reached 1,000,001 iterations and 102.9 ms in Node v24 without ever finishing. The same code in Chrome 154 spent only 34.7 ms on 300,001 iterations, which works out to 8,645,562 iterations per second and means the main thread is frozen for good. Swap in \d* and you get 132.6 ms, (?=) gives 131.0 ms, \b(x*) gives 211.4 ms, and none of them converges either.

One line rescues the whole thing: if (m.index === re.lastIndex) re.lastIndex++. With that guard the same pattern finishes in 7 rounds. Line 78 of the page uses matchAll, which carries this protection built in, so a* over banana returns 7 items, ["","a","","a","","a",""], and those 4 empty strings are exactly the places where the cursor would have stood still and had to be pushed forward.

Two forks in the substitution string, and one entry that parses nothing

Substitute into "2026-10-28" with /(\d{4})-(\d{2})/ and $1, $2 render as 2026 and 10. The three behaviours below had not been measured anywhere on the site before this run.

  • A group number that does not exist stays literal: $3 yields "$3-28" and $1$2$3$4 yields "202610$3$4-28". But $10 in a pattern with only 2 groups is not read as group 10; it decays into $1 followed by a literal 0 and produces "20260-28", while only $01 is equivalent to $1.
  • The angle bracket form $<zz> has two fates. If the pattern declares a named group, something like (?<year>\d{4}), the token renders as an empty string and the output is "-28"; if the pattern has no named group at all, the whole token survives literally and the output is "$<zz>-28". One bad token, decided by the pattern rather than by the substitution string.
  • Pass a function as the second argument and its return value lands verbatim, because no $ sequence is interpreted any more. $& and $` each mean something specific inside a string replacement, and inside a returned value they are ordinary characters.

So switching to a function buys you no extra leniency about escaping; it simply treats whatever you return as a literal.

The page truncates groups, never length

Measured on the live page: 10,000 characters against /\d+/g cost 5.6 ms and produced 667 mark elements, 1,335 nodes and an HTML string of 79,368 bytes. The same pattern over 100,000 characters cost 36.5 ms, 6,667 mark elements, 13,335 nodes and 793,368 bytes. What is genuinely expensive is the zero-width case: 100,000 characters against /(?=x)/g took 439.8 ms, emitted 100,000 mark elements, 200,000 nodes and 10,500,000 bytes, roughly 105 bytes of fixed overhead per mark. Breaking that run into stages shows matchAll at 35.6 ms, string assembly at 146.3 ms and the write into innerHTML at 409.7 ms. The page applies no limit whatsoever to input length.

mark count (log scale) 10,000 chars 100,000 chars zero-width 100,000 667 marks 6,667 marks 100,000 marks

Fig. 2: how many mark elements the same page writes into the DOM for three input sizes, with bar length drawn on a log scale. The first two rows use \d+; the third swaps in the zero-width /(?=x)/g, same character count, one order of magnitude more output.

The only truncation sits on line 104, in matches.slice(0,20): with 21 items the detail list is one row short, with 100 items it is 80 rows short, and with the 6,667 items coming from 100,000 characters it is 6,647 rows short, while the status line still prints the full total. Both the error text and the group detail are only hidden, never cleared, since line 70 and line 112 change nothing but display, and the previous full sentence of an error is measurably still sitting inside the node. That is one honest explanation for the complaint that it keeps reporting a failure even after the fix.

Q&A

Q: How do I reuse a pattern with the g flag safely?

Three options: write lastIndex back to 0 before each call; read the fields right after a hit instead of treating the next test() as a pure function; or drop g and leave the full walk to matchAll. For validation helpers the cheapest shape is to build the regex inside the helper instead of hoisting it up to module scope.

Q: Does test() returning false always mean there was no match?

Not necessarily. With g it can also mean nothing exists after the current start position, which is exactly the fourth call in Fig. 1: the whole input really does contain 3 occurrences of cat, yet past lastIndex 20 there is nothing at all.

Q: Why does pasting the pattern back into my code behave differently from the page?

Because the page rebuilds the object on every keystroke (line 69) and forces g onto it, while your object may be called by exec, test and matchAll in rotation. Separate one object from many calls and try it once more before you start suspecting the pattern itself.

📎 Related articles

The Cost Of Text Diff Is All In One (m+1)×(n+1) Table: 6000 Lines Still Finish, 8000 Lines Die On Heap Out Of Memory 2026-10-28 All 100,000 Keys Land in the DOM: Render Scale, Stack-Overflow Depth and Copy Rewrites in the JSON View 2026-10-28 27760000 Pixels Identical And Size Up 15.881 Times: The Three-Layer Byte Ledger Of JPG To PNG 2026-10-28 20 Filename Lines Leave 18 Rows In 2 Columns: Pasted Into Excel They Sit In 1 Column Until Text To Columns Rewrites 1e5 As 100000 2026-10-28 Drop The Whole Folder And The Candidate Pool Gains 1 Name: What The Random File Page Reads From A Directory 2026-10-28