2026-10-16 · 7 min read
- regex
- javascript
- parsing
A sticky expression is a cursor. lastIndex is the only index where a match may start. If the pattern does not match there, the search fails, even when the same pattern would match later in the string. A global expression without y is a search: it may start at lastIndex and then move forward until it finds a hit or runs out of text.
Sticky is what you want for a scanner that has already decided where the next token starts. It is what you do not want for a search box, a log filter, or any pattern that is allowed to skip punctuation.
See the gap before you add the flag
On ababa, a global search for a reports three matches, at indexes 0, 2, and 4. The same pattern with g and y reports one match, at 0. The character at index 1 is b. Sticky matching is not allowed to jump over it, so the later a characters are invisible to that pass.
That result is success, not a broken engine. The flag did the thing it is specified to do. If the product needed every a, the flag does not belong on that expression.
- Write down the index where the next token is required to start.
- If that index is anywhere the pattern occurs, omit y.
- If that index is the cursor, add y and move the cursor yourself.
- Log the index, the match text, and lastIndex after every attempt.
Advance the cursor when the match fails
A failed exec on a sticky or global expression sets lastIndex to 0. The next call, if you do not touch the index, starts over at the beginning. A loop that treats null as try again, with the same expression and the same string, does not make progress. It retries the same failure.
A scanner should consume the unmatched character, or emit an error that includes the index, and only then call exec. Resetting lastIndex to 0 is the engine telling you the attempt is over. It is not a suggestion to rescan.
Remember that multiline anchors still obey the cursor
With the m flag, ^ matches at the start of the string and after a line break. Sticky matching adds a second condition: the attempt starts at lastIndex. On foo bar\nfoo baz, /^foo/gm finds both lines. /^foo/gmy finds the first line only, because the cursor after foo bar is a space, not the character after the newline.
To scan every line with a sticky pattern, set lastIndex to the start of each line yourself, or drop y and keep m. Combining m and y does not mean for each line. It means at this index, which may also be a line start.
Compare one pass in the tester with the scanner log
matchAll requires a global expression. Flags of y alone throw. Flags of gy return the matches a single sticky pass can take. Flashman's regex tester compiles the pattern and runs that one pass. It shows the index and the capture groups. It builds a fresh expression every time, so it will not show a lastIndex you carried from a previous click, and it will not spin on a reset loop.
Paste the fixture twice, once with g and once with gy, and diff the index lists. If the scanner's JSON log has more hits than the sticky pass, the scanner is skipping and the flag is the wrong tool. If it has fewer hits and a repeated index of 0, the loop is retrying a failed sticky search.