Regular expressions have a steep-looking syntax that scares people away from a tool that, for a handful of common patterns, is genuinely the fastest way to solve a real problem.
The patterns that cover 90% of real use
. any character except newline
\d a digit (0-9)
\w a word character (letters, digits, underscore)
\s whitespace
+ one or more of the previous token
* zero or more of the previous token
? zero or one of the previous token (optional)
^ start of string/line
$ end of string/line
[abc] any one of a, b, or c
[^abc] any character except a, b, or c
That's genuinely most of what you'll reach for day to day — everything else is a variation or combination of these.
Matching in JavaScript
const hasDigit = /\d/.test("abc123"); // true
const digits = "abc123def456".match(/\d+/g); // ["123", "456"]
const cleaned = " hello world ".replace(/\s+/g, " ").trim(); // "hello world".test() checks for a match and returns a boolean. .match() (with the g flag) returns every match. .replace() swaps matched text — pairing it with \s+ (one or more whitespace characters) is the standard way to collapse repeated spaces into one.
Capture groups: extracting specific parts
Parentheses create a capture group — a piece of the match you can pull out separately, not just confirm exists:
const match = "2026-07-31".match(/(\d{4})-(\d{2})-(\d{2})/);
// match[0] = "2026-07-31" (the full match)
// match[1] = "2026", match[2] = "07", match[3] = "31"Named groups make this more readable for anything with more than two or three captures:
const match = "2026-07-31".match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
match.groups.year; // "2026"Common real-world patterns
// Validate a simple slug (lowercase letters, numbers, hyphens)
/^[a-z0-9]+(-[a-z0-9]+)*$/.test("my-article-slug"); // true
// Extract a hashtag
"Check out #devfieldguide today".match(/#\w+/); // ["#devfieldguide"]
// Split on multiple possible delimiters
"a, b;c d".split(/[,;\s]+/); // ["a", "b", "c", "d"]
// Strip HTML tags (naively — not safe for untrusted input, see below)
"<p>Hello</p>".replace(/<[^>]+>/g, ""); // "Hello"Greedy vs. lazy matching
By default, + and * are greedy — they match as much as possible before backtracking. Adding ? after them makes them lazy — matching as little as possible instead:
"<a><b>".match(/<.+>/); // ["<a><b>"] — greedy, matches the whole thing
"<a><b>".match(/<.+?>/); // ["<a>"] — lazy, stops at the first ">"This distinction is the single most common source of "why did my regex match way more than I expected" confusion — when a match is unexpectedly long, check whether a quantifier needs to be lazy instead of greedy.
Catastrophic backtracking: the real performance risk
Certain regex shapes — particularly nested quantifiers like (a+)+ — can cause the engine to try an exponential number of backtracking combinations on specific input, taking seconds or minutes instead of microseconds:
// Vulnerable to catastrophic backtracking on a long non-matching string
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab");This is a real denial-of-service vector (ReDoS) if a regex like this ever runs against untrusted user input. The fix is almost always restructuring the pattern to remove the nested/ambiguous quantifier, not just hoping the input stays short.
Lookahead and lookbehind: matching without consuming
Sometimes you need to assert that something follows or precedes a match, without including it in the match itself:
// Match "100" only if followed by "px" — but don't include "px" in the match
"width: 100px".match(/\d+(?=px)/); // ["100"]
// Match "px" only if preceded by digits — but don't include the digits
"width: 100px".match(/(?<=\d)px/); // ["px"](?=...) is a lookahead — it checks what comes next without consuming it. (?<=...) is a lookbehind — the same idea, checking what came before. Both are useful for splitting a match from context that needs to be present but shouldn't be part of the extracted result.
Global flag state: a common gotcha with .test()
A regex with the g flag is stateful when reused with .test() — it remembers where the last match ended, which can produce surprising alternating results:
const re = /\d/g;
re.test("a1b2"); // true
re.test("a1b2"); // true
re.test("a1b2"); // false — lastIndex has moved past both matches
re.test("a1b2"); // true — resets and starts overThis is a real, frequently-hit bug: reusing the same global regex object across multiple .test() calls on similar input can silently alternate between true and false in a way that has nothing to do with the input actually changing. The fix is either not reusing a global-flagged regex for repeated .test() calls, or resetting re.lastIndex = 0 before each one.
Common mistakes
- Using regex to parse genuinely structured formats like HTML or JSON. Regex can't correctly handle arbitrarily nested structures — use a real parser (
JSON.parse, a DOM parser, an HTML sanitizer library) for anything with real nesting, and reserve regex for flat, predictable text patterns. - Forgetting the
gflag when you need every match, not just the first..match()withoutgreturns only the first match plus capture-group details; withg, it returns all matches but drops capture-group detail — pick based on which you actually need. - Writing a regex with nested quantifiers like
(a+)+or(a*)*against input length you don't control. This is the classic catastrophic-backtracking shape — restructure to avoid the ambiguity rather than hoping input stays short. - Escaping too little or too much. Characters like
.,*,(,)are special in regex and need\\to match literally — forgetting this is a common source of a pattern matching more (or less) than intended.
Practical checklist
- Reach for a named capture group the moment a pattern has more than two or three groups —
match.groups.yearis far more maintainable thanmatch[3]. - Test regex against both matching and non-matching examples, including edge cases (empty string, very long string) — a pattern that "looks right" can still have a subtle greedy/lazy bug.
- Never build a regex dynamically from unsanitized user input without escaping special characters first — an attacker-controlled pattern fragment can turn into a ReDoS vector or an unintended match.
- For anything genuinely complex (parsing a whole config file format, validating structured data), use a proper parser or schema validator instead of stretching regex past where it's the right tool.
Related reading
- Big O Notation Without the Math Panic — shares tags: programming (same category).
- Modern Array Methods You Should Be Using Instead of Loops — shares tags: javascript, programming.
- The JavaScript Event Loop, Explained With Diagrams — shares tags: javascript, programming.
- Clean Code Principles That Actually Hold Up in Practice — shares tags: programming.
- OWASP Top 10, Explained for Developers Who Aren't Security Specialists — shares tags: programming.