RegExp lastIndex and Flags: The State That Flips Answers
Run a pattern through test, exec and matchAll: how lastIndex advances under global and sticky flags, why a reused global pattern flips its answer, and how unicode counts.
BotBrowser Team
Want the structured docs for Fingerprint?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping bounded observations about whether a pattern matched and where, but it cannot change how a runtime implements the RegExp algorithm, cannot make a global pattern stop carrying state between calls, and cannot turn a match position into an identity signal. This review runs one set of patterns through test, exec, match, replace and matchAll, then records how lastIndex advances under the global and sticky flags, why each String method resets or ignores that state differently, why a reused global pattern flips its answer on the same input, and how the unicode flag changes what a . and a quantifier count.
TL;DR
- A global pattern carries a
lastIndexthattestandexecadvance; threetestcalls on the same string returntrue,true, thenfalse, because the third call starts past the last match. - A non-global pattern keeps
lastIndexat 0, so threetestcalls on the same string all return the same answer. - The sticky flag anchors every match at
lastIndex, so/b/yfails at offset 0 onaband matches only oncelastIndexis 1. String.matchandString.replacewith a global flag resetlastIndexto 0 afterwards, which is why they do not leak state the way a reusedtestdoes.matchAllthrowsTypeErroron a non-global pattern, because it needs the global flag to walk matches./^.$/fails on an emoji and/^.$/umatches it, because without the unicode flag.counts a UTF-16 unit and the emoji is two.- The source and flags of a pattern are stable readable strings, so a record can store them exactly.
Contents
- Where lastIndex carries state between calls
- How the sticky flag anchors at lastIndex
- How String methods reset or ignore the state
- Where the unicode flag changes what a pattern counts
- Why a reused global pattern flips its answer
- Record a match reading
Where lastIndex carries state between calls
A pattern is not a pure function of its input when it carries a global or sticky flag. The RegExp object holds a lastIndex number, and test and exec read it as the start position and write it back as the position after the match. The probe calls test three times on the same string with one global pattern:
RegExp=function node=v26.9.0
fresh /a/g lastIndex=0
test("a a") -> true lastIndex=1
test("a a") again -> true lastIndex=3
test("a a") third -> false lastIndex=0
non-global lastIndex after test=true then lastIndex=0
The first call searches from index 0, finds the a at 0, returns true and sets lastIndex to 1. The second call searches from 1, finds the a at 2, returns true and sets lastIndex to 3. The third call searches from 3, finds nothing, returns false and resets lastIndex to 0. Three calls, one string, two different answers, and the only thing that changed is a number inside the pattern object.
That is the single most common defect in a regular-expression code path: a pattern created once at module scope with a global flag and reused in a loop that reads it as if it were a pure predicate. A validator that builds /^[a-z]+$/g once and calls test on each submitted name will accept the first name, then reject the second that begins where the first one's last match ended, and the rejected name looks identical to the accepted one. The failure is intermittent in a way that tracks the previous input, which makes it hard to reproduce with a single test case.
The non-global line is the control. A pattern without the global flag ignores lastIndex on entry and leaves it at 0, so three test calls on the same string return the same boolean every time. The state lives in the pattern object, not in the string, so two patterns with the same source but different flags behave differently on identical input. The rule that keeps a predicate safe is to leave off the global flag when the pattern is only being tested, and to add it only when the intent is to walk every match with exec in a loop.
How the sticky flag anchors at lastIndex
The sticky flag is the global flag's sibling with a different anchoring rule. Where a global pattern searches forward from lastIndex, a sticky pattern matches only at exactly lastIndex and fails if the match would have to start later. The probe sets lastIndex by hand to show both outcomes on the same two-character string:
sticky /b/y at 0 on "ab" -> false
sticky /b/y at 1 on "ab" -> true
exec loop indices on "a a a" -> 0,2,4
With lastIndex at 0, the sticky /b/y against ab fails, because the character at index 0 is a, not b, and a sticky match cannot skip ahead to the b at index 1. With lastIndex set to 1, the same pattern matches, because the character at index 1 is exactly b. The pattern and the string never changed; the anchoring rule turned a search into an exact-position test, and the position came from the state the caller set.
That anchoring is what makes the sticky flag useful for tokenizing: a lexer advances lastIndex deliberately after each token and expects the next pattern to match exactly where the previous token ended, so a gap or an overlap is a syntax error rather than a silent skip. It is also what makes a sticky pattern fragile in code that assumes a search: a caller who sets lastIndex to a stale offset and expects a forward search gets a failure that points at the offset rather than at the string.
The exec loop line shows the well-behaved way to use a global pattern. Because exec returns the match object and advances lastIndex, a while loop that runs until exec returns null collects every match in order and ends with lastIndex back at 0. The indices 0, 2, 4 are exactly the positions of the three a characters, and the loop terminated because the fourth exec found nothing and reset the state. A loop that calls exec once per iteration without a terminating condition, or that shares the pattern with code that also calls test, breaks this contract and skips or repeats matches.
How String methods reset or ignore the state
The String methods handle lastIndex differently from test and exec, and the difference is why the same global pattern is safe in one place and not another. The probe sets lastIndex to 2 and then calls match and replace:
before match: lastIndex=2
String.match with /g -> ["a","a","a"] lastIndex after=0
String.replace with /g -> "X X" lastIndex after=0
String.match with a global pattern ignores the incoming lastIndex, returns every match in the string as an array of the matched substrings only, and resets lastIndex to 0 afterwards. String.replace with a global pattern replaces every match, also resets lastIndex to 0, and returns the rewritten string. Both methods leave the pattern in a clean state, so a global pattern that is only ever used through match and replace does not leak the flips that a reused test produces.
That clean-up is convenient and it is also easy to misread. A caller who runs replace on a global pattern and then calls test on the same pattern sees test start from 0, which is correct, but a caller who sets lastIndex on purpose before a match expecting the match to start there is surprised, because match ignored the offset. The offset that test and exec honour is not the offset that match and replace honour, so a code path that mixes the two entry points on one global pattern has two different notions of "where to start" in the same object.
matchAll is stricter than match and enforces the global flag at the door. The probe calls it on a non-global pattern and gets an exception, then collects matches from a global pattern with capture groups:
matchAll on non-global -> THREW TypeError
matchAll groups -> a=1@0 b=2@3 c=3@6
matchAll throws TypeError on a non-global pattern because it returns an iterator that walks every match and it needs the global flag to define that walk. On a global pattern it yields a match object per match, with the capture groups and the index of each match, so the collected a=1@0 b=2@3 c=3@6 shows three matches with their two groups and their start positions. Unlike a while loop over exec, matchAll does not leave the caller responsible for advancing lastIndex, so it is the safer way to walk every match on a pattern that might also be used elsewhere.
Where the unicode flag changes what a pattern counts
The unicode flag changes the unit a pattern counts from a UTF-16 code unit to a Unicode code point, and that changes both . and any quantifier over a character. The probe tests a single-character pattern and a two-character quantifier against an emoji:
/^.$/.test("😀") -> false
/^.$/u.test("😀") -> true
/^.{2}$/u.test("😀") -> false (length=2)
Without the unicode flag, . matches one UTF-16 code unit, and the emoji is a surrogate pair of two units, so /^.$/ cannot match a two-unit string with a one-unit pattern and returns false. With the unicode flag, . matches one code point, and the emoji is a single code point, so /^.$/u returns true. The same emoji that the plain pattern sees as two units the unicode pattern sees as one character, and a validator built on /^.$/ rejects an emoji that a reader counts as one character.
The third line shows the inverse trap. A quantifier written as {2} under the unicode flag counts two code points, not two units, so /^.{2}$/u against the single-code-point emoji returns false even though the string's .length is 2. A caller who reads String.length (which is always the UTF-16 unit count) and writes a quantifier to match it gets a pattern that disagrees with the string's own length under the unicode flag. This is the same unit-versus-character gap that a text decoder records, and it is a place where a pattern silently counts a different thing than the code that wrote it assumed.
Together the two lines say that the unit a pattern counts is a property of the flag, not of the pattern text. A length check, a .{n} quantifier and a . all count UTF-16 units without the flag and code points with it, so a pattern that mixes a String.length check against a u pattern is comparing two different units. Recording the flag alongside the pattern is what lets a later reader reproduce the same counting.
Why a reused global pattern flips its answer
The most dangerous form of the state defect is a global pattern reused across calls, because it produces alternating answers on identical input. The probe calls test three times on the string 1 with one global /\d/g:
reused /\d/g test three times on "1" -> true,false,true
The first call matches the digit, sets lastIndex to 1, and returns true. The second call starts at 1, the string is length 1, so there is nothing left to search, and it returns false and resets lastIndex to 0. The third call starts from 0 again and returns true. The pattern is correct, the string never changed, and the answer alternates true, false, true purely because the pattern remembers where it stopped.
An empty-match pattern needs one extra rule, and the probe shows the result of applying it:
empty-match walk -> [0:"a"] [1:""] [2:""] [3:""]
/a*/g can match an empty string at every position, and exec would loop forever if lastIndex did not advance past a zero-length match. The probe checks whether lastIndex still equals the match index and, if so, increments it, which is why the walk advances [0:"a"], then an empty match at 1, at 2 and at 3 instead of repeating at the same offset. Without that manual bump an exec loop on an empty-match pattern never terminates, because each call returns a zero-length match at the same lastIndex and the position never moves.
The fix for the reused-pattern defect is to keep the state out of the shared object. A global pattern used only inside a single while loop over exec is fine, because that loop owns the lastIndex. A global pattern used as a reusable predicate is wrong, and the fix is either to drop the global flag or to reset lastIndex to 0 before each test. The source and flags properties are the cheap way to record what a pattern was, because they are stable readable strings that survive a round trip through a record.
Record a match reading
A match reading supports a narrow claim: this runtime, this pattern text, these flags, given this input, produced this match at this position. Six fields keep it checkable, and none of them depends on the private state of a shared object:
- The pattern
sourceand itsflags, recorded as the two strings the object exposes, because the flag set decides both the unit a pattern counts and whether it carries state. - The input string as supplied, ideally with its code points rather than its rendered glyphs, because two inputs can render the same and a pattern counts units or code points according to its flags.
- The entry point used to run the pattern,
test,exec,match,replaceormatchAll, because each one treatslastIndexdifferently. - The
lastIndexbefore and after the call when the pattern is global or sticky, because the number is where the state lives and a stored answer without it cannot be reproduced. - The match index and the captured groups, rather than only the matched substring, because two matches of the same text at different positions are different observations.
- Whether a zero-length match required a manual
lastIndexbump, because an empty-match pattern needs that step to terminate and a record that omits it describes a loop that would have hung.
What such a reading cannot show is anything about how a server or a second runtime will interpret the same pattern. A pattern is run by one engine with one set of flag semantics, and a proxy, a WAF or a server framework may parse the same string with its own rules. For the byte layers a pattern often runs over, TextDecoder and TextEncoder shows why the same bytes decode to one string under one label and a mojibake string under another, and ArrayBuffer and TypedArray shows why the same eight bytes carry a different element count under each view. A pattern over either kind of data inherits the unit gap those articles measure.
Two habits keep the reading honest over time. First, record the flags and the entry point together with the answer, because a true from a global test and a true from a non-global test mean different things about the next call. Second, prefer matchAll or a single while loop over exec for walking matches and a non-global pattern for a reusable predicate, so the state that flips answers never reaches a second caller that assumed a pure function.
Public sources
- MDN: RegExp
- MDN: RegExp.prototype.test
- MDN: RegExp.prototype.exec
- MDN: String.prototype.matchAll
- MDN: RegExp sticky flag
- BotBrowser: automation consistency reference
Practical conclusion
Treat the flag set as part of the data contract: a global or sticky pattern carries a lastIndex that test and exec advance, so three calls on one string return true, true, then false, while a non-global pattern keeps lastIndex at 0 and returns the same answer each time. Know that the sticky flag anchors at lastIndex, that match and replace reset lastIndex to 0 and matchAll demands the global flag, and that the unicode flag makes . and a quantifier count code points where the default counts UTF-16 units. Leave the global flag off a reusable predicate, walk matches with matchAll or one exec loop that owns the state, and record the source, the flags, the entry point and the lastIndex before and after so a stored answer can be reproduced rather than misread as a pure result.
Next reading
For the text layer a pattern usually runs over, read TextDecoder and TextEncoder; for the binary views a pattern may scan, read ArrayBuffer and TypedArray; for the order a serializer gives the keys of the object a pattern matched, read Object key order.
This article is part of the guide: What Is a Browser Fingerprint?
Related Articles
Unicode Normalization: Four Text Identities a Page Reads
FingerprintstructuredClone vs JSON: What a Deep Copy Actually Keeps
FingerprintCrypto-JS Random Source: Which Branch Your Build Uses
Fingerprintua-parser-js: Client Hints, Bots and a 500-Char Cap
FingerprintWhat Is a Browser Fingerprint?
IdentityBrowsing History Seeding: Session vs Global History
Take BotBrowser from research to production
The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.