Skip to content

Volume ITechnology

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 backslashes

Use 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:

PatternMatchesNotes
.any character except newlinewith the s flag, newlines too
\d \Ddigit / non-digit[0-9] only, unless u + \p{Nd}
\w \Wword char / not[A-Za-z0-9_] — ASCII only, a common bug with names
\s \Swhitespace / notincludes 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 propertyrequires 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 lookbehind

Lookarounds 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 slot

Prefer 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 ​

FlagNameWhat it does — and the trap
gglobalfind all matches — and makes the regex stateful (section 5, the big trap)
iignore casecase-insensitive; Unicode-aware only with u
mmultiline^ and $ match at line boundaries, not just string boundaries
sdotAll. also matches newline
uunicodecorrect handling of astral characters; enables \p{…}; should be your default
ystickymatch only at lastIndex, no scanning forward — the tokenizer's flag (3.11)
dindicesadds .indices with start/end offsets per group
vunicodeSetsu 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 ​

THE BUGconst re = /\d+/g; ← ONE object, reusedre.test("a1") → true lastIndex = 2re.test("a1") → FALSE ← starts at 2! lastIndex = 0re.test("a1") → true …alternatingsame input, different answersWHY`g` and `y` make the regex OBJECT carrymutable `lastIndex` — a cursor into thelast string it saw. It is shared state.THE FIXES① don't use `g` for a boolean test② use `str.matchAll(re)` for all matches③ never share a `g` regex across calls/modules
Figure 1 — A stateful regex. With 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 itself

The 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 replacement

Reach 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 entirely

Why. 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, best first:

  1. Use a negated character class instead of . or a nested quantifier. In /<[^>]+>/, each character can be matched in only one way, so the engine never has to try alternatives. This one change fixes most real cases.
  2. Anchor the pattern and be specific. Replace .* with the actual set of characters you mean.
  3. Make the ambiguity impossible. /^(?:\w+(?:\s\w+)*)$/ says "words separated by spaces", and there is only one way to split any input.
  4. Limit the input length. Check the length before matching. An attacker's power grows exponentially with input length, so a 200-character cap turns a hang into a short delay.
  5. Do not use a regex at all. split(' ') and a loop is often clearer, and its time grows only in a straight line with the input.
  6. For patterns you do not control, use a linear-time engine such as RE2. It refuses backreferences and lookarounds, because those are exactly the features that 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 finds all matches, and makes the regex remember where it stopped.
    • i ignores case.
    • m makes ^ and $ match at each line.
    • s lets . match a newline.
    • u should be your default. It works in whole characters and allows \p{…}. Without it, /^.$/ fails on an emoji.
    • y is sticky, used for tokenizers.
    • d returns the positions of matches.
  • The lastIndex trap: a g/y regex is a mutable object with a cursor, so a shared const re = /x/g gives alternating answers on identical input. Use matchAll, or drop g for 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 forever

Identical 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. Do not use g for a yes-or-no question. test on a regex without g remembers nothing between calls, and that is what you meant.
  2. Use str.matchAll(re) to get all matches with their groups and positions. It leaves no shared state behind, and it replaces the old while ((m = re.exec(s)) !== null) loop completely.
  3. If you must reuse a g regex, set re.lastIndex = 0 before each separate use. Or create the regex inside the function, so each call gets a fresh one.
  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) uses one weakness of backtracking engines. Before deciding that an input does not match, the engine may have to try an exponential number of ways to match it.

The cause is ambiguity: a pattern where the same piece of text can be shared out between quantifiers in many ways. Examples are (a+)+, (\w+\s?)*, (a|a)* and .*.*. When the input matches, the engine often succeeds early. When the input fails, the engine has to try every way of sharing it out, and the number of ways grows exponentially with the length. A specially made 40-character string against /^(\w+\s?)*$/ can run for minutes.

Why Node is hit especially hard. A regex runs synchronously, without ever pausing. It does not hand control back to the event loop in the middle of a match. So one bad request freezes the whole process: every other connection, every timer and every health check (3.8.1). The load balancer then marks the server as unhealthy and moves traffic elsewhere, and the attacker does it again. One request takes down one core, and then the whole service. And it arrives through an ordinary form field.

How to find it. Code review does not catch it, because the pattern looks normal. Normal inputs do not catch it either, because they match. So use tools: ESLint rules for unsafe regexes, and ReDoS scanners in CI. And check every regex that runs on input a user controls. That is the only place it matters.

How to fix it, in order:

  1. Replace . and nested quantifiers with negated character classes, such as [^>]+ instead of .+?. That removes the ambiguity completely.
  2. Rewrite the pattern so there is only one possible way to split the text, such as ^(?:\w+(?:\s\w+)*)$ instead of ^(\w+\s?)*$.
  3. Limit the input length before matching, because the attacker's power grows exponentially with length.
  4. Where patterns are built at runtime or come from outside, use a linear-time engine such as RE2. It guarantees O(n) by refusing the features that need backtracking.

Never compile a pattern a user typed in. With a backtracking engine, there is no safe way to do it.

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.

Stop it happening again:

  • Run a ReDoS linter in CI, and make it block the merge.
  • Monitor event-loop lag and alert on it. A 30-second freeze should page someone. The missing alert is why this took so long to find (10.10).
  • Make it a standing rule: any regex that touches untrusted input must have a length limit.

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 char

Each choice is deliberate.

[^\s<>"'\]+, not .+?` or a nested quantifier. With a negated class, every character can be matched in only one way. So the engine never has to try alternatives, and the pattern cannot suffer ReDoS (section 7). That matters a lot here, because this input always comes from strangers. The characters left out are the ones that reliably end a URL in normal text and in markup.

u, because user text contains non-English characters, and without u the engine splits some of them in half.

g, because we want every match. And so we use matchAll, never a shared test, which avoids the lastIndex trap from section 5.

Then clean up afterwards, instead of making the pattern clever. Text often puts punctuation right after a URL, such as ., ,, ) or ]. Trim it off. But keep a closing bracket if the URL has a matching opening one, as Wikipedia links often do. This is a small loop, and it is far easier to read and get right than a regex that tries to do the same.

Then check each candidate with a real parser. Pass it to new URL(candidate) inside a try/catch. The platform's URL parser has the final say on what a valid URL is, and it also tidies the URL up for you. A regex that finds candidates plus a parser that checks them is far more correct than any single regex. This split, a cheap search followed by a careful check, has the same shape as every find-then-verify design in this book.

Safety layers, because the input may be hostile:

  • Limit the input length before scanning, and limit how many matches you return. A 5 MB comment with 200,000 URLs is an attack on your resources, even with a fast pattern.
  • If you will fetch the URLs, only allow listed domains, and block private and internal addresses. Otherwise an attacker can make your server call its own internal systems, the attack called SSRF (9.9.5, 11.21).
  • If you will show the URLs on a page, escape them. Something shaped like a URL is not automatically safe HTML.

The defence in short: use a deliberately simple pattern that cannot backtrack to find candidates, a real parser to check them, and a small cleanup step for the punctuation around them. The alternative, one "complete" URL regex, is unreadable, hard to maintain, still wrong, and possibly a security hole.