JavaScript Regex & the RegExp Object

How regular expressions actually behave in JavaScript — the methods, the stateful lastIndex trap, named groups, modern flags, and how to keep a pattern from blocking your event loop.

Last updated

In short

JavaScript regular expressions are RegExp objects, written either as a literal between slashes (/\d+/g) or via new RegExp(str, flags). The one thing that surprises everyone: a regex with the g or y flag isstateful — it remembers where it stopped in lastIndex, so calling.test() twice on the same object gives different answers.

Creating a regular expression

// Literal — compiled at parse time. Use this when the pattern is fixed.
const phone = /^\d{3}-\d{4}$/;

// Constructor — use when the pattern is assembled at runtime.
const term = escapeRegExp(userInput);
const search = new RegExp(term, "gi");

// The constructor takes a *string*, so every backslash must be doubled:
new RegExp("\\d+")   // equivalent to /\d+/
new RegExp("\d+")    // WRONG — "\d" is just "d" after string parsing

Prefer the literal. It reads better, needs no double-escaping, and is validated when the file is parsed rather than when the line first runs. Reach for the constructor only when part of the pattern is a variable — and escape that variable first:

function escapeRegExp(str) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

const re = new RegExp(`\\b${escapeRegExp(query)}\\b`, "i");

The methods, and which to use

CallWhat it returns
re.test(str)Returns true or false. The fastest option when you only need a yes/no answer — but see the lastIndex trap.
re.exec(str)Returns a match array or null. With the g flag, call repeatedly to walk through every match.
str.match(re)Without g, returns one match array with groups. With g, returns a flat array of matched strings and no group data.
str.matchAll(re)Returns an iterator of full match arrays, groups included. Requires the g flag.
str.replace(re, r)Replaces the first match, or every match if the g flag is set. The replacement can be a function.
str.replaceAll(re, r)Replaces every match. Throws if the regex lacks the g flag — which makes the intent explicit.
str.search(re)Returns the index of the first match, or -1. Ignores lastIndex.
str.split(re)Splits on every match. Capturing groups are included in the output array.

The decision is usually one of four:

  • Just need a boolean? re.test(str) — without the g flag.
  • Need every match plus its groups? [...str.matchAll(re)] with the g flag.
  • Need one match with its groups? str.match(re) without g.
  • Rewriting text? str.replaceAll(re, fn).

The lastIndex trap

This is the single most common JavaScript-specific regex bug, and it is worth understanding rather than memorising a workaround.

const re = /\d+/g;

re.test("abc 123");   // true   — lastIndex is now 7
re.test("abc 123");   // false  — resumes from index 7, finds nothing
re.test("abc 123");   // true   — lastIndex reset to 0 by the failure

// Same problem inside a loop over an array:
const isNumeric = /^\d+$/g;
["1", "2", "3"].filter((s) => isNumeric.test(s));   // ['1', '3'] — wrong

Three fixes, in order of preference:

  1. Drop the g flag if you are only testing. A boolean check never needs it.
  2. Create the regex where you use it. A literal inside the callback is a fresh object each call — modern engines cache the compilation, so the cost is negligible.
  3. Reset explicitly: re.lastIndex = 0 before each use. Necessary when the regex must be a shared module constant.

Note that String.prototype.match, matchAll, and search do not have this problem — match with g resets internally, andmatchAll operates on a clone.

Capture groups and matchAll

const log = "GET /a 200\nPOST /b 404";
const line = /(?<method>GET|POST) (?<path>\S+) (?<status>\d{3})/g;

for (const m of log.matchAll(line)) {
  console.log(m.groups.method, m.groups.status, m.index);
}

// Straight into objects
const rows = [...log.matchAll(line)].map((m) => ({ ...m.groups }));

// Destructuring named groups reads especially well
const { groups: { year, month } = {} } =
  "2026-03-14".match(/(?<year>\d{4})-(?<month>\d{2})/) ?? {};

Compare that with match under the global flag, which throws the structure away:

"GET /a 200".match(/(GET|POST) (\S+)/g);
// ['GET /a']            ← groups gone

"GET /a 200".match(/(GET|POST) (\S+)/);
// ['GET /a', 'GET', '/a', index: 0, input: '...', groups: undefined]

A group inside an alternation that did not participate is undefined, not an empty string — guard before calling string methods on it. With the ES2022 d flag you also get match.indices, giving the start and end offset of every group, which is what you need to build a syntax highlighter or an editor decoration.

replace and replaceAll

// $1, $2 for numbered groups; $<name> for named ones
"2026-03-14".replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1");
// '14/03/2026'

"Ada Lovelace".replace(/(?<first>\w+) (?<last>\w+)/, "$<last>, $<first>");
// 'Lovelace, Ada'

// $& is the whole match, $` before it, $' after it, $$ a literal dollar
"cost: 40".replace(/\d+/, "$&.00");   // 'cost: 40.00'

// A function replacement receives (match, ...groups, offset, string, groups)
text.replaceAll(/(?<user>[\w.+-]+)@(?<domain>[\w.-]+)/g, (...args) => {
  const { user, domain } = args.at(-1);
  return `${user[0]}***@${domain}`;
});

replaceAll throws a TypeError if the regex lacks the gflag. That looks unhelpful but is a feature: it turns "I forgot the g flag and only replaced the first one" from a silent data bug into an immediate error.

Flags

FlagEffectExample
gGlobal — find every match rather than stopping at the first/\d+/g
iCase-insensitive/error/i
mMultiline — ^ and $ match at every line break/^\w+/gm
sDotall — . matches newlines too (ES2018)/<p>.*<\/p>/s
uUnicode mode — enables \p{...} and correct astral-character handling (ES2015)/\p{Lu}/u
vUnicode sets — set difference and intersection inside classes (ES2024)/[\p{L}--\p{Lu}]/v
ySticky — match only at exactly lastIndex, never later/\d+/y
dIndices — adds match.indices with the start/end of every group (ES2022)/(\w+)/d

Read the flags off an existing regex with re.flags, and clone one with different flags via new RegExp(re.source, "gi"). Since ES2018 you can also scope flags to part of a pattern in engines that support modifier groups, but support is still uneven — set flags on the whole pattern for now.

Unicode

Without the u flag, JavaScript regexes operate on UTF-16 code units, which means emoji and other astral characters are two "characters" as far as the engine is concerned:

const grin = "\u{1F600}";     // a grinning face, one code point

grin.length;                  // 2 — two UTF-16 code units
/^.$/.test(grin);             // false — . matches one code unit
/^.$/u.test(grin);            // true  — u makes . a full code point

// Property escapes need the u (or v) flag
/\p{Lu}/u.test("Ä");          // true  — any uppercase letter, any script
/\p{Script=Greek}/u.test("π"); // true
/\p{Emoji_Presentation}/u.test(grin); // true

// The v flag (ES2024) adds set operations inside classes
/[\p{Letter}--\p{ASCII}]/v.test("é");  // true — letters except ASCII ones

Use u by default on any pattern that touches user-generated text. For counting or splitting user text, remember that even code points are not the same as what a reader calls a character — use Intl.Segmenter for grapheme clusters.

Modern features worth knowing

  • Named capture groups (ES2018) — (?<name>...), read viamatch.groups.
  • Lookbehind (ES2018) — and uniquely, JavaScript allows it to be variable-length: (?<=\$\d+\s) is valid where Python would reject it.
  • Dotall (ES2018) — the s flag, so you no longer need[\s\S] to match across lines.
  • String.prototype.matchAll (ES2020) — replaces thewhile ((m = re.exec(s)) !== null) loop entirely.
  • Match indices (ES2022) — the d flag populatesmatch.indices and match.indices.groups.
  • Unicode sets (ES2024) — the v flag brings set difference (--), intersection (&&), and multi-character string properties inside character classes.

Performance and ReDoS

JavaScript runs on a single thread, so a pathological regex does not just slow a request down — it freezes the whole event loop, or the whole browser tab. That makes ReDoS a genuine availability issue rather than a micro-optimisation concern.

// Exponential: every 'a' doubles the work
const evil = /^(a+)+$/;
evil.test("a".repeat(30) + "!");    // blocks for seconds

// Linear: same intent, no nesting
const safe = /^a+$/;

// Lazy dot vs negated class — the negated class cannot overshoot
/".*?"/       // backtracks on every character
/"[^"]*"/     // one pass
  • Never nest quantifiers where the inner and outer can match the same text.
  • Prefer negated classes to lazy dots when you know the terminator.
  • Anchor patterns so a non-match fails at the first character instead of retrying at every offset.
  • Escape user input before it becomes part of a pattern.
  • Hoist regexes out of hot loops — a module-level constant avoids re-creating the object, though modern engines cache literal compilation anyway.
  • Use a plain string method when one existsincludes, startsWith, and split are faster and clearer.

Recipes

Validation helpers
const PATTERNS = {
  email: /^[\w.+-]+@[\w-]+\.[\w.]+$/,
  slug: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
  hex: /^#(?:[0-9a-f]{3}){1,2}$/i,
  isoDate: /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/,
  ipv4: /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/,
};

export const isValid = (kind, value) => PATTERNS[kind].test(value);
Highlight every match in a string, safely
function highlight(text, query) {
  const re = new RegExp(escapeRegExp(query), "gi");
  const out = [];
  let last = 0;
  for (const m of text.matchAll(re)) {
    out.push(text.slice(last, m.index), { match: m[0] });
    last = m.index + m[0].length;
  }
  out.push(text.slice(last));
  return out;   // render strings as text, objects as <mark>
}
Parse a query string without a library
const PAIR = /(?<key>[^&=]+)=(?<value>[^&]*)/g;

const params = Object.fromEntries(
  [...search.matchAll(PAIR)].map((m) => [
    decodeURIComponent(m.groups.key),
    decodeURIComponent(m.groups.value),
  ])
);

Every pattern in the library ships with a JavaScript snippet, and you can try any of them in the regex tester — which runs the same ECMAScript engine your code will. For the syntax itself, see theregular expressions guide, and for the same material in Python, the Python regex guide.

JavaScript Regex Questions

How do I write a regular expression in JavaScript?

There are two forms. A regex literal is written between slashes with the flags after the closing slash: /\d{3}-\d{4}/g. The RegExp constructor takes a string, which is useful when the pattern is built at runtime: new RegExp(`^${escaped}$`, "i"). Prefer the literal when the pattern is fixed — it is compiled once at parse time and needs no double-escaping of backslashes.

Why does regex.test() alternate between true and false?

Because the regex has the g or y flag. Those flags make the RegExp object stateful: it stores a lastIndex property and resumes searching from there on the next call. Calling test() twice on the same object with the same input therefore returns true, then false. Fix it by dropping the g flag when you only need a boolean, by creating the regex inside the function, or by setting re.lastIndex = 0 before each call.

What is the difference between match and matchAll in JavaScript?

String.prototype.match with a global regex returns a flat array of matched strings and discards all capture-group information. matchAll returns an iterator of full match arrays, so each result keeps its groups, named groups, and index. Use matchAll whenever you need the captured pieces, and spread it into an array with [...str.matchAll(re)] to work with the results.

How do I use named capture groups in JavaScript?

Write the group as (?<name>...) and read it back from the groups object on the match: match.groups.year. Named groups landed in ES2018 and are supported in every current browser and Node. In a replacement string, reference them as $<name>. Note that Python uses (?P<name>...) instead — the P is required there and invalid in JavaScript.

Does JavaScript support lookbehind?

Yes, since ES2018, and unlike Python or PCRE, JavaScript allows variable-length lookbehind — (?<=\$\d+) is valid. It is supported in all current browsers including Safari from version 16.4, and in Node from version 9. If you must support older Safari, feature-detect by constructing the regex inside a try/catch, because an unsupported lookbehind is a syntax error at compile time rather than a runtime failure.

How do I escape a string before putting it in a JavaScript regex?

JavaScript has no built-in RegExp.escape in widely-shipped engines yet, so escape it yourself with str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"). Do this to any user input before interpolating it into a pattern — otherwise a stray parenthesis throws a SyntaxError and a stray .* becomes a denial-of-service vector.

What is ReDoS and how do I avoid it in JavaScript?

ReDoS — regular expression denial of service — happens when a pattern with nested quantifiers over overlapping alternatives is run against crafted input, causing exponential backtracking that blocks the single-threaded event loop. Avoid nesting quantifiers such as (a+)+, prefer negated character classes over lazy dots, anchor patterns so they fail fast, and never build a pattern from unescaped user input.