Regular expressions: the useful 20% that handles 90% of jobs
The regular expression syntax worth memorising, how a pattern is matched step by step, the JavaScript methods that use them, named groups, and the two mistakes (greedy quantifiers and catastrophic backtracking) that cause real bugs.
A regular expression is a small language for describing patterns in text. It looks like line noise, but the part you need day to day is about fifteen symbols, and the way they combine is regular enough to reason about. This tutorial teaches that subset, shows how the engine walks through a string so you can predict what a pattern will match, and ends with the mistakes that turn a one-line validator into an outage. Try every pattern in the regex tester as you go.
Literal characters and the escape
Most characters match themselves: /cat/ matches "cat" inside "concatenate". The characters with special meaning are . * + ? ^ $ { } ( ) [ ] | \ and / in a JavaScript literal; to match one of them literally, put a backslash in front: /\./ matches a dot, /\$\d+/ matches a dollar sign followed by digits.
Character classes
| Pattern | Matches |
|---|---|
. | Any character except a newline (with the s flag, newline too) |
\d / \D | A digit 0 to 9 / anything that is not |
\w / \W | A "word" character: letter, digit or underscore (ASCII only, so not é) / anything that is not |
\s / \S | Whitespace: space, tab, newline and friends / anything that is not |
[abc] | One of a, b or c |
[a-z0-9] | One character in those ranges |
[^abc] | One character that is not a, b or c |
Inside square brackets most special characters lose their meaning; only \, ], ^ (at the start) and - (between characters) are special. [.] matches a literal dot.
Quantifiers
| Pattern | Repeats the preceding item |
|---|---|
a* | Zero or more times |
a+ | One or more times |
a? | Zero or one time |
a{3}, a{2,5}, a{2,} | Exactly 3, 2 to 5, at least 2 |
Quantifiers are greedy: they take as many characters as they can and give back only if the rest of the pattern cannot match. Appending ? makes them lazy (as few as possible). The classic example is matching an HTML tag:
'<b>bold</b> and <i>italic</i>'.match(/<.+>/)[0] // '<b>bold</b> and <i>italic</i>' greedy: the whole thing
'<b>bold</b> and <i>italic</i>'.match(/<.+?>/)[0] // '<b>' lazy
'<b>bold</b> and <i>italic</i>'.match(/<[^>]+>/)[0] // '<b>' best: cannot cross a >The third form is usually better than the lazy one: a negated class says precisely what the match may contain, and it is faster because the engine never has to try and retry.
Anchors and boundaries
^ matches at the start of the string and $ at the end (with the m flag, at the start and end of each line). Without them, a validator like /\d{4}/ happily accepts "abc1234xyz". \b matches a word boundary, the position between a \w and a \W character, so /\bcat\b/ matches "cat" but not "concatenate".
Groups and alternation
Parentheses group a sub-pattern so a quantifier applies to the whole of it ((ab)+) and capture what they matched. | is "or", and binds loosely, so /^cat|dog$/ means "starts with cat, or ends with dog"; you usually want /^(cat|dog)$/. Named groups make results readable:
const date = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/;
const m = '2026-10-11'.match(date);
m.groups.year // '2026'
m[1] // '2026' (numbered access still works)
const { year, month, day } = '2026-10-11'.match(date).groups;A group you do not need to capture can be written (?:...), which is slightly faster and keeps the numbering of the groups you do want.
How matching actually proceeds
The engine starts at position 0 and tries to match the pattern there. If it fails, it moves to position 1 and tries again, and so on. Within one attempt it goes left to right, and whenever there is a choice (a quantifier could take more or fewer, an alternation could pick either branch) it takes the first option and remembers the others. If a later part of the pattern fails, it backtracks to the most recent choice and tries the next option. Understanding this predicts everything: why greedy takes the longest, why the first alternative that can match wins even if a later one would match more, and why some patterns take forever (next section).
The JavaScript methods
const re = /(\w+)@(\w+)\.com/g;
const text = 'ana@example.com, ben@sample.com';
re.test(text) // true (does it match anywhere?)
text.match(/(\w+)@/) // ['ana@', 'ana', index: 0, ...] first match with groups
text.match(re) // ['ana@example.com', 'ben@sample.com'] with g: all matches, no groups
[...text.matchAll(re)] // all matches WITH groups (needs g)
text.replace(re, '$2:$1') // 'example:ana, sample:ben'
text.replace(re, (m, user, host) => user.toUpperCase()) // function replacement
text.split(/,\s*/) // ['ana@example.com', 'ben@sample.com']
text.search(/ben/) // 17 (index) or -1Flags: g (all matches), i (case-insensitive), m (multi-line anchors), s (dot matches newline), u (Unicode-aware, so . matches an emoji as one character and \p{L} matches any letter in any script). A g regex object remembers lastIndex between test/exec calls, which is a well-known trap when you reuse one in a loop: it can start searching from where the previous call stopped and report "no match" on a fresh string. Use matchAll, or reset lastIndex = 0.
Three patterns worth copying
// Trim and collapse whitespace
s.trim().replace(/\s+/g, ' ')
// Make a URL slug from a title
title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
// Permissive email shape check (real validation is "send an email to it")
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)Catastrophic backtracking
Some patterns make the engine try an exponential number of paths before giving up. The shape to recognise is a quantified group whose contents can themselves match in several ways, followed by something that fails: /^(\w+\s?)*$/ against a long string that ends in a !. For each word the engine can split it many ways between the \w+ and the next iteration, and with 30 characters that is about a billion attempts. In the browser this freezes the tab; on a server it is a denial-of-service hole, because the input comes from the user.
Avoid it by making each repetition unambiguous: /^(\w+(\s|$))*$/ forces the \w+ to end at whitespace or the end. Prefer negated classes ([^,]+) over .+, and when the input is untrusted, cap its length before matching. Node.js and browsers have no regex timeout.
Pitfalls
- Forgetting anchors in a validator; it matches a substring.
- Unescaped dots:
/example.com/also matches "examplexcom". - Parsing HTML or JSON with regex. Use the DOM parser or
JSON.parse; the formats are not regular. - Reusing a global regex with
testin a loop (thelastIndextrap). - Over-strict validators for names, addresses and emails. Real data is messier than your pattern.
Exercise
In the tester, write a pattern that extracts the hour and minute from times like "9:05", "14:30" and "23:59" with named groups, and rejects "24:00" and "9:5". Then write one that finds every #hashtag in a sentence but not the # in "item #4". Finally, paste (\w+\s?)*$ with a 30-character test string ending in "!" and watch the tab stall; close it, and rewrite the pattern so it answers instantly.
Summary
- Classes (
\d \w \s [...]), quantifiers (* + ? {n,m}), anchors (^ $ \b), groups and|: that is the core. - Greedy by default; prefer a negated class to a lazy quantifier.
matchAllfor all matches with groups;replacewith$1or a function.- Make repeated groups unambiguous and cap untrusted input, or a regex can hang your server.