Regular expressions5 min read

JavaScript RegExp lastIndex Makes Global and Sticky Matching Stateful

Control RegExp lastIndex safely with global and sticky matches, zero-length patterns, test, exec, and matchAll.

  • regular expressions
  • lastIndex
  • text processing

A JavaScript RegExp with the g or y flag is mutable. Calls to exec() and test() read and write its lastIndex property. Reusing one instance can therefore change a later result even when the pattern and input string are identical.

Use a non-global expression for a repeatable yes-or-no test. Use a fresh global expression or matchAll() for whole-string iteration. Reach for sticky mode when you are building a scanner that must match exactly at a cursor.

exec and test share the same cursor

The ECMAScript 2026 matching algorithm starts a global or sticky match at lastIndex. A successful match stores its end position. A failed match resets lastIndex to zero.

const word = /cat/g;

console.log(word.test('cat cat'), word.lastIndex); // true 3
console.log(word.test('cat cat'), word.lastIndex); // true 7
console.log(word.test('cat cat'), word.lastIndex); // false 0
console.log(word.test('cat cat'), word.lastIndex); // true 3

test() does not have separate state. Its specification calls the same regular-expression execution operation as exec() and reduces the result to a boolean.

This makes a module-level global expression a poor validator:

const hasDigits = /\d/g;

console.log(hasDigits.test('room 7')); // true
console.log(hasDigits.test('room 7')); // false

const stableHasDigits = (text) => /\d/.test(text);
console.log(stableHasDigits('room 7')); // true
console.log(stableHasDigits('room 7')); // true

Removing g is the clean fix when the caller only needs a boolean. Setting lastIndex = 0 before each call also works, but it keeps unnecessary mutable state in the design.

Global mode searches forward

With g, the engine begins at lastIndex and may scan later positions until the pattern matches. After success, lastIndex points just after the match.

const identifier = /[A-Za-z_]\w*/g;
const source = 'let total = price';
const names = [];

let match;
while ((match = identifier.exec(source)) !== null) {
  names.push({ value: match[0], start: match.index, end: identifier.lastIndex });
}

console.log(names);
// [
//   { value: 'let', start: 0, end: 3 },
//   { value: 'total', start: 4, end: 9 },
//   { value: 'price', start: 12, end: 17 }
// ]

This loop is appropriate when each match object, capture, or index is needed. A failure ends the loop and resets the expression. If other code calls the same expression while the loop is in progress, both consumers will fight over one cursor. Do not share a stateful instance across interleaved operations.

Sticky mode enforces contiguous parsing

The y flag also uses lastIndex, but it does not scan ahead. The match must begin exactly at the cursor. On failure, it resets the cursor to zero.

const numberToken = /\d+/y;
const input = '12,34';

numberToken.lastIndex = 0;
console.log(numberToken.exec(input)[0]); // 12

numberToken.lastIndex = 3;
console.log(numberToken.exec(input)[0]); // 34

numberToken.lastIndex = 2;
console.log(numberToken.exec(input));    // null
console.log(numberToken.lastIndex);     // 0

That strict position rule is useful for tokenizers. The parser owns the cursor, skips permitted separators itself, and treats a failure at the current location as invalid input. Global mode would silently jump past unrecognized text.

The indices count UTF-16 code units. With the u or v flag, the engine advances by a full code point while searching, but lastIndex and match indices remain string indices measured in code units. That matters around supplementary Unicode characters.

Empty matches need explicit progress with exec

A successful empty match can leave lastIndex unchanged. A hand-written exec() loop then repeats forever unless it advances the cursor.

const boundary = /(?=a)/g;
const input = 'aa';
const positions = [];

let match;
while ((match = boundary.exec(input)) !== null) {
  positions.push(match.index);
  if (match[0] === '') boundary.lastIndex += 1;
}

console.log(positions); // [0, 1]

Adding one is correct for this ASCII input. For arbitrary Unicode text, manual advancement must avoid landing between surrogate halves. This is a good reason to use matchAll() when it fits. The standard’s regular-expression string iterator advances empty matches with its Unicode-aware AdvanceStringIndex operation.

matchAll isolates iteration state

String.prototype.matchAll requires a global regular expression. It constructs a matcher from the original expression, copies the starting lastIndex, and advances the new matcher as the iterator is consumed. The original instance is not moved by the iteration.

const pair = /(\w+)=(\d+)/g;
pair.lastIndex = 4;

const matches = [...'x=1;y=20;z=300'.matchAll(pair)];

console.log(matches.map((item) => [item[1], item[2], item.index]));
// [['y', '20', 4], ['z', '300', 9]]
console.log(pair.lastIndex); // 4

The specified @@matchAll method copies the original cursor, so set it to zero or use a new literal when iteration should begin at the start. The iterator also handles zero-length progress.

String methods do not all treat lastIndex alike. search() ignores the caller’s cursor, match() resets a global expression before collecting matches, and replace() has its own matching procedure. Check the method’s contract instead of assuming every RegExp consumer advances the original object.

Mutable matching state is useful when one owner controls it. Bugs appear when a validation helper, parser, and iterator unknowingly share the same regular expression. Make that ownership visible, or avoid the g and y flags where no cursor is needed.