How to Use Regex in JavaScript Without Surprises Properly
Use regex in JavaScript without surprises: literals and RegExp, test, match and matchAll, the g flag and lastIndex, named groups and replace callbacks.

JavaScript has a capable regex engine, with lookbehind, named groups and Unicode support, and a few API habits that produce wrong results with no error. The most famous is the statefulness of global regexes. This post covers the methods and the gotchas, and relates to the general patterns in regex differences across tools. The examples assume Node.js 22 and modern browsers.
Create and test
const re = /(\d{4})-(\d{2})-(\d{2})/;
console.log(re.test("due 2026-11-03")); // true
const m = "due 2026-11-03".match(re);
console.log(m[0], m[1], m.index); // 2026-11-03 2026 4
const dynamic = new RegExp("^" + "v\\d+", "i"); // backslashes doubled in a string
console.log(dynamic.test("V12")); // truetrue
2026-11-03 2026 4
true
A literal is compiled once when the script loads and is the usual form. The constructor is for patterns built at run time, where you must double each backslash inside the string. match without the g flag returns an array with the whole match, the groups and an index, or null when nothing matches, so check before indexing.
All matches
const text = "a=1, b=22, c=333";
for (const m of text.matchAll(/(\w)=(\d+)/g)) {
console.log(m[1], m[2]);
}
console.log(text.match(/\d+/g)); // ["1", "22", "333"]a 1
b 22
c 333
[ '1', '22', '333' ]
matchAll returns every match with its groups, and throws a TypeError if the regex lacks the g flag. match with g returns only the matched strings and drops the groups. For pre-ES2020 code, a loop with exec does the same.
The lastIndex trap
const re = /cat/g;
console.log(re.test("cat"), re.test("cat"), re.test("cat")); // true false true
const safe = /cat/;
console.log(safe.test("cat"), safe.test("cat")); // true trueA regex with the g (global) or y (sticky) flag stores lastIndex after a match, and the next test or exec begins there. The second call fails, resets it to 0 and the third succeeds. Do not use g with test, and create a new regex inside a function rather than sharing a global one across calls. The same state makes a module-level /x/g a classic source of intermittent bugs.
Named groups and replace
const { groups } = "2026-11-03".match(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/);
console.log(groups.d + "/" + groups.m + "/" + groups.y);
console.log("2026-11-03".replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, "$<d>/$<m>/$<y>"));
console.log("price: 5 and 12".replace(/\d+/g, (n) => String(n * 2)));03/11/2026
03/11/2026
price: 10 and 24
Named groups document what each part means and are read through groups. In a replacement string, $<name> inserts a named group and $1 a numbered one. A function replacement receives the match and groups and returns the new text, which is more readable than packing logic into the pattern. The topic is expanded in named capture groups.
User input and flags
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(escape("a.b (c)"), "i");
console.log(re.test("A.B (C)")); // trueNever pass raw user input to new RegExp: special characters change the meaning and a crafted pattern can hang the engine, as in catastrophic backtracking. Escape it first. Useful flags: i ignore case, m multiline anchors, s dot matches newline, u Unicode mode (needed for \p{L} and code points beyond the basic plane) and d to return match indices.
The sticky flag and lastIndex
A regular expression with the global or sticky flag keeps state: the property lastIndex records where the next search begins. Calling test or exec repeatedly on the same object with the same string walks through the matches, and when a search fails, lastIndex resets to zero. The result is a notorious bug: a pattern defined once with the g flag and used in a function alternates between true and false for the same input, because the second call starts where the first stopped. Avoid the flag when you only want a yes or no answer, create the pattern inside the function, or reset lastIndex to zero before each use.
The sticky flag y matches only at lastIndex, with no scanning forward, which is the right tool for writing a tokenizer: it makes each rule match at exactly the current position.
Iterating over matches
For all matches in a string, String.prototype.matchAll returns an iterator of match objects, each with groups, index and the input, and it requires the global flag. It is cleaner than looping with exec, and it does not mutate the pattern's lastIndex, since it works on a copy. Spread it into an array when you need a list: [...text.matchAll(re)]. The older match with the global flag returns only the matched strings with no groups, which loses information. Use replaceAll or replace with a global pattern when you want to transform the text instead of listing matches, and pass a function as the replacement to compute each substitution from the groups.
Group indexes are also available with the d flag, which adds an indices array giving the start and end of each group. That is useful for highlighting or editing in place.
Unicode and lookbehind
By default, the engine works on UTF-16 code units, so characters outside the basic plane, such as many emoji, count as two. The u flag switches to code points and enables Unicode property escapes, and the newer v flag adds set operations in classes. Use one of them whenever the text can contain more than ASCII. Lookbehind assertions, (?<=...) and (?<!...), are supported in current engines, but older Safari versions lacked them, so check the target browsers before relying on them. In Node.js and modern browsers the features work as described, and a syntax error at load time is how an unsupported feature reveals itself, which is a reason to test on the oldest environment you ship to.
Named groups appear in the groups object, and in replacement strings as $<name>, which keeps patterns readable and stable under later insertions.
Safety with user input
Never build a pattern from user input without escaping it, and never run an expensive pattern on unbounded text. Nested quantifiers such as (a+)+$ backtrack exponentially on near-matches, and the JavaScript engine runs on the main thread, so one bad input freezes a page or a server's event loop. Limit the length of the subject, avoid ambiguous repetition, and prefer simple string methods like includes, startsWith and split when they do the job. Test patterns against hostile strings of several thousand characters. For server code that must accept arbitrary patterns, run them in a worker with a timeout, or use a linear-time engine through a library. Keep patterns in named constants with a comment, and cover them with tests that include the awkward inputs, so a refactor does not change their meaning unnoticed.
Put the checks in tests that run in the same engine you ship to. A browser test suite or Node runs the same patterns the users will, and catches differences in flag support between environments. Include strings with emoji, accented letters, line breaks and very long runs of the same character in the cases. A regular expression is code, and the awkward inputs are where it fails; ten minutes spent listing them for each important pattern saves a bug report from a user whose name did not fit.
Prefer named constants for patterns used in more than one place, so the flags and the escaping are decided once and reviewed once.
Where this does not apply
For structured formats like HTML, JSON or URLs, use a parser: JSON.parse and new URL(...) are safer than any pattern. Lookbehind works in current engines but not in old Safari versions. The ECMAScript flavour differs from PCRE: no possessive quantifiers or atomic groups, and no recursion. See the MDN regular expressions guide and the ECMAScript specification.


