Appearance
3.6.10 — Regular Expressions
Every validator, every log parser, every router (9.9.2), every "find and replace" in your editor runs one. Regular expressions are the most-used and least-understood tool in a working programmer's kit — people copy them from Stack Overflow, they mostly work, and then one day a single innocuous-looking pattern hangs a production server for minutes on a 40-character input. This page builds the mental model from 1.7's theory down to the engine's actual behaviour, covers every construct you will meet, and finishes with the security topic nobody taught you: catastrophic backtracking.
1. What a regex actually is
A regular expression is a pattern describing a set of strings. In 1.7's terms it denotes a regular language, and the classic implementation is a finite automaton that walks the input once. JavaScript's engine is not that. Like Perl, Python, Java, and almost every practical language, it is a backtracking engine — it tries one possibility, and if that fails, rewinds and tries another. This buys features a true finite automaton cannot express (backreferences, lookahead) and costs the worst-case guarantee that a finite automaton gives you for free. That trade is the whole of section 7, and everything surprising about regex performance follows from it.
Two ways to make one:
javascript
const literal = /^\d{3}-\d{4}$/; // ← literal: compiled once, at parse time
const built = new RegExp(`^${area}-\\d{4}$`, "u"); // ← from a string: note DOUBLED backslashesUse the literal unless the pattern is dynamic. When it is dynamic, remember that user input inside a new RegExp is an injection vector — a user supplying (a+)+$ can hang your process (section 7), so untrusted fragments must be escaped, and untrusted whole patterns must simply never be compiled.
2. The construct vocabulary, completely
Character classes — one character each:
| Pattern | Matches | Notes |
|---|---|---|
. | any character except newline | with the s flag, newlines too |
\d \D | digit / non-digit | [0-9] only, unless u + \p{Nd} |
\w \W | word char / not | [A-Za-z0-9_] — ASCII only, a common bug with names |
\s \S | whitespace / not | includes tabs, newlines, and exotic Unicode spaces |
[abc] [^abc] | set / negated set | [a-z] ranges; inside a class most metacharacters are literal |
\p{L} \p{Nd} | Unicode property | requires the u flag — the correct way to match "any letter" |
Quantifiers — how many of the preceding item:
javascript
/a*/ // 0 or more (greedy)
/a+/ // 1 or more (greedy)
/a?/ // 0 or 1 (greedy)
/a{2,4}/ // 2 to 4 (greedy)
/a+?/ // 1 or more — LAZY: match as FEW as possible
/a++/ // possessive — NOT supported in JavaScript (Java/PCRE only)Greedy versus lazy is the single most common source of wrong-but-plausible regexes. /<.+>/ against <b>hi</b> matches the whole string — .+ grabs everything, then backtracks just enough to find a final >. /<.+?>/ matches <b> — lazy quantifiers expand only as needed. Neither is "correct"; they answer different questions, and the fix is usually neither but a negated class: /<[^>]+>/, which cannot overshoot in the first place and — as section 7 shows — is also the fast, safe version.
Anchors and boundaries match positions, not characters:
javascript
/^abc$/ // ← start / end of STRING (or of each line, with the m flag)
/\bcat\b/ // ← word boundary: matches "cat" in "the cat sat", not in "concatenate"
/(?<=\$)\d+/ // ← lookbehind: digits PRECEDED by $, without consuming the $
/\d+(?= USD)/ // ← lookahead: digits FOLLOWED by " USD", without consuming it
/(?<!un)happy/ // ← negative lookbehindLookarounds are zero-width assertions: they test a condition at the current position and consume nothing, which is what lets you match "the number, but only when it's a price" and get back just the number.
3. Groups, captures, and references
javascript
const re = /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/; // ← NAMED capture groups
const { groups } = "2026-07-22".match(re);
groups.y; // → "2026" ← readable, refactor-safe, self-documenting
/(\w+)\s+\1/.test("the the"); // → true — \1 is a BACKREFERENCE to group 1
/(?:https?):\/\//; // ← (?: ) is a NON-CAPTURING group: grouping without a slotPrefer named groups ((?<name>…)) over numbered ones the moment you have more than one — numbered groups renumber themselves silently when someone adds a group in the middle, which is a bug that survives review. Use (?:…) whenever you need grouping only for alternation or quantification; capturing costs allocation and clutters the result.
Backreferences (\1, \k<name>) are exactly the feature that makes JavaScript regexes more powerful than regular languages — /(\w+) \1/ matches a doubled word, which no finite automaton can do (1.7). They are also part of why the engine must backtrack.
4. The flags, and what each really changes
| Flag | Name | What it does — and the trap |
|---|---|---|
g | global | find all matches — and makes the regex stateful (section 5, the big trap) |
i | ignore case | case-insensitive; Unicode-aware only with u |
m | multiline | ^ and $ match at line boundaries, not just string boundaries |
s | dotAll | . also matches newline |
u | unicode | correct handling of astral characters; enables \p{…}; should be your default |
y | sticky | match only at lastIndex, no scanning forward — the tokenizer's flag (3.11) |
d | indices | adds .indices with start/end offsets per group |
v | unicodeSets | u plus set operations inside classes ([\p{L}--[aeiou]]) |
Why u should be your default: without it, JavaScript regexes operate on UTF-16 code units, so /^.$/.test("👍") is false — the emoji is two code units and . matches one (3.6.7). With u, . matches one code point and the test passes. Any regex touching user-supplied text without u has a class of bugs waiting for its first non-ASCII input.
5. The lastIndex trap — regex objects have memory
g or y, a regex literal is a mutable object with a cursor. A module-level const re = /…/g reused across requests is a shared-state bug that produces intermittent, input-independent wrong answers — one of the hardest JavaScript bugs to reproduce.javascript
const re = /\d+/g;
re.test("a1"); // → true (lastIndex now 2)
re.test("a1"); // → false ← THE BUG: resumed from index 2
// Correct alternatives
/\d+/.test("a1"); // ← no `g` for a yes/no question
[..."a1 b2".matchAll(/\d+/g)]; // ← matchAll: fresh iteration, no shared cursor
"a1 b2".match(/\d+/g); // ← returns all matches, resets lastIndex itselfThe same trap bites exec in a while loop (correct — that uses lastIndex deliberately) versus a g regex passed into a helper function (broken — the helper inherits a cursor it doesn't know about). Rule: a regex with g is a stateful object; treat it like one, or don't share it.
6. The methods, and which to reach for
javascript
"2026-07-22".match(/\d+/g); // → ["2026","07","22"] all matches, strings only
[..."a1b2".matchAll(/(\w)(\d)/g)]; // → full match objects WITH groups + index
/(\d+)/.exec("abc 42"); // → ["42","42", index:4] one match, groups included
/\d/.test("abc"); // → false fastest yes/no
"a-b-c".split(/-/); // → ["a","b","c"]
"a1b2".replace(/\d/g, d => d * 2); // → "a2b4" ← replacer FUNCTION receives each match
"a1b2".replaceAll("1", "X"); // ← string replaceAll; with a regex it REQUIRES `g`
"2026-07".replace(/(?<y>\d+)-(?<m>\d+)/, "$<m>/$<y>"); // → "07/2026" named refs in replacementReach for matchAll by default when you want every match with its groups and positions — it is the modern, non-stateful API and it removes the exec-in-a-while-loop idiom entirely. Use test for booleans, replace with a function whenever the replacement depends on what matched (far clearer than stacking $1 references), and remember String.raw when building patterns from template literals so backslashes survive.
7. Catastrophic backtracking — the security topic
Here is a pattern that looks completely ordinary and is a denial-of-service vulnerability:
javascript
const re = /^(\w+\s?)*$/; // "words separated by optional spaces"
re.test("An input string that takes a long time or even makes this pattern hang!");
// ↑ on a ~40-char non-matching input this can run for MINUTES, blocking the event loop entirelyWhy. The engine backtracks. (\w+\s?)* is a quantifier inside a quantifier, so the same input can be split into groups in exponentially many ways — "aaa" as one group of 3, or 3 groups of 1, or 1+2, or 2+1… For an input that ultimately fails to match (here, the trailing !), the engine must try every one of those splits before it can conclude failure. Add one character and the work roughly doubles: O(2ⁿ). This is ReDoS, and in Node it is uniquely severe because the regex runs synchronously on the single thread — one malicious input freezes the entire process, every connection, every timer (3.8.1).
The signatures to recognize in review — all involve ambiguity about how input can be divided:
- Nested quantifiers:
(a+)+,(a*)*,(\w+\s?)*,(.*)* - Alternation with overlap under a quantifier:
(a|a)*,(\d|\w)+(every digit is also a word char, so each character has two ways to match) - Adjacent quantifiers over overlapping classes:
\s*\s*,.*.*
The fixes, in order of preference. ① Use a negated character class instead of . or a nested quantifier — /<[^>]+>/ cannot backtrack ambiguously because each position has exactly one interpretation; this single change fixes most real cases. ② Anchor and be specific — replace .* with the actual character set you mean. ③ Make the ambiguity impossible — /^(?:\w+(?:\s\w+)*)$/ expresses "words separated by spaces" with only one possible split. ④ Bound the input — validate length before matching; an attacker's leverage is exponential in input length, so a 200-character cap converts a hang into a delay. ⑤ Don't use a regex — split(' ') and a loop is often clearer and linear. ⑥ For untrusted or generated patterns, use a linear-time engine (RE2 via a binding), which refuses backreferences and lookarounds precisely because those are what force backtracking.
And the operational rule: run a ReDoS linter in CI (ESLint has rules; dedicated scanners exist), because this vulnerability is invisible to code review, untriggered by tests with well-formed inputs, and identical in appearance to a working regex.
8. The expert lens
Regexes are write-only unless you make them readable. Three habits: name your groups, build long patterns from documented pieces (a RegExp composed from named string fragments beats a 200-character literal), and write the test cases first — including the ones that should not match, which is where every regex bug lives. A pattern with no negative test cases has not been tested.
Know when to stop. Regexes cannot match nested structures — HTML, JSON, balanced parentheses — because those are not regular languages (1.7); a pattern that appears to parse HTML works on your examples and fails on the real web. Parse with a parser (3.11). Similarly, email validation by regex is a famous tarpit: the pattern that fully implements the specification is thousands of characters long and still doesn't tell you whether the address exists — validate with /^[^@\s]+@[^@\s]+\.[^@\s]+$/ and then send a confirmation email, which is the only real validation.
Performance beyond ReDoS: compile once (hoist literals out of loops — though modern engines cache them), prefer test over match when you only need a boolean, use the y flag for tokenizers so the engine doesn't rescan, and remember that for simple fixed substrings includes/indexOf beats any regex.
Recall
- JavaScript uses a backtracking engine, not a finite automaton — which buys backreferences and lookarounds and costs the linear-time guarantee (1.7). Everything surprising about regex performance follows from that one fact.
- Greedy (
+) vs lazy (+?):/<.+>/on<b>hi</b>matches everything;/<.+?>/matches<b>;/<[^>]+>/is the version that's both correct and fast. Lookarounds(?=)(?!)(?<=)(?<!)are zero-width — they test without consuming. - Flags:
g(all matches + statefulness),i,m(line anchors),s(dot matches newline),u— should be your default (code points,\p{…}; without it/^.$/fails on an emoji),y(sticky, for tokenizers),d(indices). - The
lastIndextrap: ag/yregex is a mutable object with a cursor, so a sharedconst re = /x/ggives alternating answers on identical input. UsematchAll, or dropgfor boolean tests, and never share a global regex. - ReDoS: nested quantifiers (
(a+)+,(\w+\s?)*), overlapping alternation ((a|a)*), and adjacent quantifiers over overlapping classes cause exponential backtracking on failing inputs — freezing Node's single thread entirely. Fix with negated classes, unambiguous structure, input-length caps, and a CI linter; never compile untrusted patterns.
Self-test: Why isn't JavaScript's engine a finite automaton, and what does that buy and cost? Rewrite /<.+>/ correctly and say why your version is also faster. Which flag should be on by default and what breaks without it? Reproduce the lastIndex bug in three lines. Name three ReDoS signatures and the first fix to try.
Quiz Bank
FoundationalWhat is the difference between greedy and lazy quantifiers, and what is usually the better third option?
A greedy quantifier (*, +, ?, {n,m}) consumes as much as possible, then gives characters back one at a time until the rest of the pattern can match. A lazy quantifier (*?, +?, ??) consumes as little as possible, then takes one more character at a time until the rest matches. Classic demonstration on <b>hi</b>: /<.+>/ matches the entire string (.+ swallows everything, backtracks to the final >), while /<.+?>/ matches just <b>. Neither is "right" — they answer different questions, and choosing between them is a decision about which boundary you mean.
The better third option is almost always a negated character class: /<[^>]+>/. It matches <b> like the lazy version, and it is structurally better because [^>] cannot match >, so there is exactly one way for the engine to consume each character — no backtracking is possible, which makes it both faster and immune to the ReDoS class (section 7). The general principle worth carrying: when you find yourself reaching for a lazy quantifier, ask what characters you are actually trying to exclude and say so explicitly — the negated class expresses your real intent, is easier to read, and removes ambiguity that the engine would otherwise have to explore.
AppliedExplain the lastIndex trap with a concrete failure and give the fixes.
A regex with the g or y flag is a mutable object carrying a cursor (lastIndex) into the last string it examined. So:
javascript
const re = /\d+/g;
re.test("a1"); // true, lastIndex → 2
re.test("a1"); // FALSE — search resumed at index 2, found nothing, reset lastIndex → 0
re.test("a1"); // true again… alternating foreverIdentical input, different answers, depending on invisible state. Where it actually bites in production: a module-level const EMAIL = /.../g used by a validator across many requests — the second request in a burst fails validation for no reason, intermittently, and it is un-reproducible in a unit test that constructs a fresh regex. It also bites when a g regex is passed into a helper that calls test or exec, since the helper inherits a cursor it has no idea exists.
Fixes: (1) don't use g for a boolean question — test with a non-global regex is stateless and is what you meant; (2) use str.matchAll(re) to get all matches with their groups and indices — it iterates without leaving shared state behind, and it replaces the old while ((m = re.exec(s)) !== null) idiom entirely; (3) if you must reuse a global regex, reset re.lastIndex = 0 before each independent use, or construct the regex inside the function so each call gets a fresh object; (4) never export a g regex from a module. The underlying lesson is broader than regex: a literal that looks like a constant but holds mutable state is one of the most confusing bug shapes in any language, and /x/g is JavaScript's most common instance of it.
InterviewWhat is ReDoS, why is it especially dangerous in Node, and how do you find and fix it?
ReDoS (regular-expression denial of service) exploits the fact that a backtracking engine may need to try an exponential number of ways to match an input before concluding that it doesn't match. The precondition is ambiguity: a pattern where the same substring can be divided among quantifiers in many ways — (a+)+, (\w+\s?)*, (a|a)*, .*.*. For a matching input the engine often succeeds early; for a failing input it must exhaust every division, and the count of divisions is exponential in input length. A 40-character crafted string against /^(\w+\s?)*$/ can run for minutes. Why Node is uniquely exposed: regex execution is synchronous and unyielding — it does not return to the event loop mid-match — so a single malicious request freezes the entire process: every other connection, every timer, every health check (3.8.1). The load balancer then sees the instance as unhealthy, traffic shifts, and the attacker repeats. One request, one core, whole-service outage — and it arrives through a form field. How to find it: it is invisible to code review (the pattern looks normal) and invisible to tests (which use well-formed inputs that match). So use tooling — ESLint rules for unsafe regexes, dedicated ReDoS scanners in CI — and audit every regex applied to user-controlled input, which is the only place it matters. How to fix, in order: replace . and nested quantifiers with negated character classes ([^>]+ instead of .+?), which removes the ambiguity structurally; restructure so there is exactly one possible split (^(?:\w+(?:\s\w+)*)$ instead of ^(\w+\s?)*$); cap input length before matching, since the attacker's leverage is exponential in length; and where patterns are dynamic or untrusted, use a linear-time engine such as RE2, which guarantees O(n) by refusing the features that require backtracking. Never compile a user-supplied pattern — that is remote code execution's less famous cousin, and there is no safe way to do it with a backtracking engine.
StaffA log-processing service intermittently stalls: CPU pegged at 100% on one core, event loop blocked for 30+ seconds, no errors logged. Diagnose from first principles.
The symptom triad — one core saturated, event loop blocked, no errors — is a synchronous CPU-bound operation, and in a log processor the overwhelmingly likely candidate is a regex on adversarial input (3.8.1). Three properties fit: the work is synchronous (so the loop is blocked, not merely busy), it produces no error (the regex is working, just exponentially), and it is intermittent (only certain log lines trigger it).
Confirm before fixing. Take a CPU profile during a stall (--cpu-prof, or --inspect with a captured profile, or process._rawDebug breadcrumbs if the process is too wedged to attach): a ReDoS stall shows nearly 100% of samples inside RegExp execution with an almost-empty JavaScript stack above it — unmistakable. Correlate stall timestamps with the input being processed; log processors usually have the offending line recoverable from an offset or a checkpoint, and reproducing the hang locally with that single line takes minutes once you have it.
Then audit every regex applied to log content, looking for the section 7 signatures — nested quantifiers, (a|a)*-style overlapping alternation, adjacent .*. Log lines are the perfect ReDoS carrier: they're attacker-influenced (a user agent, a URL, a form value echoed into a log), often long, and frequently malformed — and it is failing inputs that trigger the exponential path.
Fix in three layers. Immediate: cap the length of any field fed to a regex (a 2 KB cap on a log field is harmless and turns exponential into bounded), and add a guard that skips lines exceeding a threshold with a counter so you can see how often it fires.
Structural: rewrite the offending patterns with negated character classes and unambiguous grouping; where the parse is genuinely structured (JSON logs, a known format), replace the regex with a parser or a split, which is both linear and clearer. Architectural: move log parsing off the main thread into a worker thread or a separate process (3.8.6) so that even an unbounded parse cannot block request handling — this is the real fix for a service whose job is processing untrusted text, because it converts a whole-process outage into a slow worker.
Prevent recurrence: a ReDoS linter in CI as a blocking check; a fuzz test that throws random malformed strings at the parser with a per-line time budget assertion; event-loop-lag monitoring with an alert (a 30-second block should page, and the absence of that alert is why this went undiagnosed — 10.10); and the standing rule that any regex touching untrusted input requires a negative test case and a length bound.
The principle to write down: in a single-threaded runtime, any unbounded synchronous computation on untrusted input is an availability vulnerability — regexes are simply the most common one, because they are the only place where a few innocuous characters buy an attacker exponential work.
Flashcards
FlashBacktracking engine
JS regexes are backtracking, not finite automata. Buys backreferences + lookarounds; costs the linear-time guarantee. All performance surprises follow.
FlashGreedy / lazy / better
/<.+>/ matches everything · /<.+?>/ matches <b> · /<[^>]+>/ is correct AND unambiguous AND fast. Reach for the negated class.
FlashThe u flag
Default it on. Without u, regexes work on UTF-16 code units: /^.$/ fails on an emoji. With u you get code points and \p{L} properties.
FlashlastIndex trap
g/y make the regex object stateful. Shared const re = /x/g alternates true/false on identical input. Use matchAll; drop g for boolean tests; never export a g regex.
FlashReDoS signatures
Nested quantifiers (a+)+ · overlapping alternation (a|a)* · adjacent quantifiers .. — exponential on FAILING inputs, freezes Node's whole thread. Fix: negated classes, unambiguous structure, length caps, CI linter, RE2 for untrusted patterns.
Scenario Drill
DrillYou must extract every URL from arbitrary user-submitted text, safely and correctly. Design the solution and defend each choice.
First, challenge the requirement, because "every URL" is under-specified and the answer changes the design. Are bare domains (example.com) URLs? Is www.example.com without a scheme? What about URLs inside markdown link syntax, inside code blocks, or truncated at a line break? The pragmatic scope that survives contact with real text is: absolute URLs with an explicit http/https scheme, plus optionally a separate, clearly-labeled pass for scheme-less domains — because a pattern that tries to catch both reliably produces false positives on ordinary sentences ("see fig.3 in section 2.1" contains something that looks like a hostname). The pattern, built for safety not cleverness:
javascript
const URL_RE = /https?:\/\/[^\s<>"'`]+/gu; // ← negated class: one interpretation per charEach design choice is deliberate. **[^\s<>"'\]+** rather than .+? or a nested quantifier: a negated class means every character has exactly one way to match, so the engine cannot backtrack ambiguously and the pattern is **ReDoS-immune by construction** (section 7) — which matters enormously here, since the input is by definition untrusted. The excluded characters are the ones that reliably terminate a URL in prose and markup. **u** because user text contains non-ASCII and code-unit semantics produce wrong boundaries. **g** because we want all matches — and therefore we use **matchAll**, never a shared test, avoiding the lastIndex trap (section 5). **Then post-process rather than over-engineer the pattern.** Trim trailing punctuation (., ,, ), ]) that prose attaches to URLs, balancing parentheses if the URL legitimately contains them (a Wikipedia link does) — this is a small loop that is far easier to read, test, and get right than the regex that would attempt it. **Then validate with a real parser:** feed each candidate to new URL(candidate)inside atry/catch`; the platform's URL parser is the authority on what is a valid URL, and it also normalizes for you. A regex that finds candidates plus a parser that validates them is dramatically more correct than any single regex, and this division — cheap scan, authoritative parse — is the same shape as every retrieval-then-verify design in this book. Safety layers that must exist because the input is hostile: cap the input length before scanning and cap the number of matches returned (a 5 MB comment containing 200,000 URLs is a resource attack even against a linear pattern); if the extracted URLs will be fetched, apply an allowlist and block private/link-local addresses (the SSRF class — 9.9.5, 11.21); and if they will be rendered, escape them as output rather than trusting that a URL-shaped string is safe HTML. The test suite is the deliverable, not the pattern: URLs at string start and end, adjacent to punctuation, inside parentheses, with query strings and fragments and percent-encoding, with unicode domains, malformed schemes, and — the cases that catch real bugs — strings that must NOT match, since a regex with only positive tests is untested. The defence in one sentence: use a deliberately simple, backtracking-free pattern to find candidates, a real parser to validate them, and explicit post-processing for prose boundaries — because the alternative, a single "complete" URL regex, is unreadable, unmaintainable, still wrong, and possibly a vulnerability.