JavaScript regex: flags, groups, lookahead, backtracking

Test JavaScript regexes: flags g i m s u v y, capture and named groups, lookahead, replace with $1, and avoid catastrophic backtracking like (a+)+$.

On this page

To test a JavaScript regular expression, type the pattern without its slashes, switch on the flags you need, paste some sample text, and read which parts match and what each capture group caught. A good tester highlights the matches live, lists groups with their positions and previews the result of a replacement. This guide covers the seven flags and what each changes, capture groups and named groups, lookahead and lookbehind, replacing with $1, the code traps that bite everybody once, and the pattern (a+)+$, which can freeze a program for real. Every example below was run in JavaScript and gives the output shown.

Testing a pattern step by step

  1. Open the regex tester and type the pattern in the first box, without the surrounding slashes. \d{4}-\d{2} is the pattern; /\d{4}-\d{2}/g is how it looks in source code. The flags are separate chips.
  2. Switch the flags you need. g is on by default so that every match is found and not only the first.
  3. Paste the text to search. Matches are highlighted in two alternating colours so neighbouring matches stay distinguishable.
  4. Read the table: each match with its start and end position, and one row per capture group. A group that did not take part in the match is shown as such, which is different from a group that matched an empty string.
  5. To rewrite the text, fill in "Replace with" and read the preview.

It uses your browser's own regex engine, so it is the JavaScript (ECMAScript) flavour. That is the right one for JavaScript, TypeScript and Node.js. Python, PHP, Java and .NET share most everyday syntax but not everything: for example, Python writes a named group as (?P<name>...) while JavaScript writes (?<name>...).

The flags

FlagNameEffectExample
gglobalFind every match, not just the first"a-a-a".replace(/a/g, "x") gives x-x-x; without g it gives x-a-a
iignore caseLetters match in either case/việt/i matches VIỆT
mmultiline^ and $ match at every line break/^b$/m finds b in "a\nb"; without m it finds nothing
sdotAll. also matches a line break/a.b/s.test("a\nb") is true; without s it is false
uunicodeWorks on code points; enables \p{...}/^.$/u.test("😀") is true; without u it is false
vunicode setsStricter successor of u with set operations/[\p{L}--[a-z]]/v matches é but not e
ystickyMatch only exactly at the current position/foo/y fails on "barfoo" until lastIndex is 3

Two cautions. The shorthand \w covers only ASCII letters, digits and the underscore, so /\w+/.exec("Việt Nam")[0] is just Vi. For letters of any language use /\p{L}+/u, which matches the whole word Việt. And positions are counted in UTF-16 code units, which is how JavaScript's own index works, so an emoji without the u flag takes two positions. The v flag is newer; a browser without it shows that chip disabled.

Capture groups

Parentheses make a capture group, numbered from 1 by the position of the opening parenthesis. (\w+), (\w+) on Doe, John captures Doe as group 1 and John as group 2. Replace with $2 $1 and you get John Doe. Use (?:...) when you need grouping but no capture, which keeps the numbering simple.

A group that does not take part is undefined, not an empty string: /(a)?b/.exec("b") gives ["b", undefined]. That is the difference the tester shows between "did not take part" and "empty".

Named groups

Names beat numbers when a pattern grows. With (?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2}) on 2026-10-13, the groups are year = 2026, month = 10 and day = 13. Replace with $<day>/$<month>/$<year> and the text becomes 13/10/2026. Inside the pattern, \k<name> repeats a named group: /(?<c>\w)\k<c>/ finds the double letter ll in hello.

Lookahead and lookbehind

A lookaround checks what is next to the match without including it.

  • (?=...) positive lookahead: what follows must match.
  • (?!...) negative lookahead: what follows must not match.
  • (?<=...) positive lookbehind: what precedes must match.
  • (?<!...) negative lookbehind: what precedes must not match.

Two useful cases. Adding thousands separators: \B(?=(\d{3})+(?!\d)) with the g flag and a replacement of , turns 1234567 into 1,234,567, and 1234567.891 into 1,234,567.891. Matching a number only after a dollar sign: (?<=\$)\d+ finds 45 in price: $45, cost: €30 and ignores the 30. A pile of lookaheads can also check several conditions at once, as in ^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$, which accepts Abcdef12 and rejects abcdef12. That is a demonstration, not a good password policy; length matters more than character classes.

Replacing

In the replacement text, $1 to $99 are numbered groups, $<name> is a named group, $& is the whole match and $$ is a literal dollar sign. Three results that surprise people:

  • "cat".replace(/a/, "[$&]") gives c[a]t.
  • "ab".replace(/(a)/, "$2") gives $2b, because there is no group 2, so the text stays as it is.
  • "ab".replace(/(?<x>a)/, "$<y>") gives b, because a name that does not exist is replaced with nothing.

Without the g flag only the first match is replaced, which is the most common reason a replace "does not work".

Greedy, lazy and empty matches

Quantifiers are greedy: they take as much as they can. On <b>bold</b> and <i>italic</i>, the pattern <.+> finds one match, the whole string. Add a question mark and <.+?> is lazy and finds four: <b>, </b>, <i> and </i>.

A pattern that can match nothing, like x*, matches an empty string at every position. On ab with g it matches three times, at positions 0, 1 and 2, and a replacement of - gives -a-b-. The tester marks empty matches with a thin bar.

Code traps

  • `lastIndex` and the `g` or `y` flag. A regex object with g remembers where it stopped. const re = /a/g; re.test("a"); re.test("a"); gives true, then false. Create the regex fresh, reset re.lastIndex = 0, or leave out g when you only need a yes or no.
  • `matchAll` requires `g`. "aa".matchAll(/a/) throws a TypeError. With g, [..."a1b22".matchAll(/\d+/g)] gives matches 1 at index 1 and 22 at index 3.
  • Forgetting to escape. . matches any character, so write \. for a dot. The same goes for ( ) [ ] { } + ? ^ $ | \.
  • Pasting slashes and flags into the pattern. In the tester type only the part between the slashes.

Catastrophic backtracking

Some patterns take exponentially longer with every extra character. The classic is ^(a+)+$. Tested against a run of the letter a that ends in b, the engine tries every possible way of splitting the a characters between the inner and the outer repeat before it gives up. These are times measured in Node.js 22, which uses the same V8 engine as Chrome, on one laptop:

Pattern, with aaa…ab as the text24 characters26 characters28 characters
^(a+)+$83 ms334 ms1,320 ms
(a+)+$ (no ^)166 ms647 ms2,632 ms
^a+$under 0.1 msunder 0.1 msunder 0.1 ms

Your times will differ, but the shape will not: each extra character roughly doubles the work. The same happens with ^(a|a)+$ and ^(a*)*$ on a run of a followed by b, and with ^(\w+\s?)+$ on a long word followed by an exclamation mark; at 26 characters these took between 330 and 650 ms. On a server this is a real denial-of-service weakness, known as ReDoS, and a user-supplied text or pattern is all it takes.

How to avoid it:

  • Remove the nested repeat. ^a+$ gives the same answer as ^(a+)+$ in microseconds.
  • Do not repeat overlapping alternatives. (a|a)+, (a|aa)+ and (\w|\d)+ ask the engine to choose between options that match the same text.
  • Bound the repeats, such as {1,20}, and limit the length of the text you test.
  • Emulate an atomic group. ^(?=(a+))\1$ takes the a characters once, with no way to give them back, and fails on 5,000 a followed by b in well under a millisecond.
  • For untrusted patterns, use a linear-time engine such as RE2, not a backtracking one.

The tester runs every search in a background worker and stops it after 2 seconds with a message, so the tab stays responsive and you can find the problem before it reaches production.

Mistakes to avoid

  • Using a regex to parse HTML or to validate every legal e-mail address. A pattern is fine for finding candidates; nested tags and the full e-mail grammar need a real parser.
  • Expecting ^ and $ to work per line without m.
  • Forgetting g when replacing.
  • Assuming \d and \w cover every language. They do not.

To see exactly what a replacement changed, paste the before and after into the diff checker; the article on comparing two texts explains how to read the result. For plain clean-ups that need no pattern, the text toolkit is simpler.

A short checklist

Type the pattern without slashes, turn on g for all matches, use u for non-English text, name groups once a pattern has more than two, check lastIndex in code, and test any pattern with a nested repeat against a long non-matching string before you trust it.

Tools mentioned in this article

Back to the blog