JAVASCRIPT / REGULAR EXPRESSIONS
Quantifiers and greedy against lazy matching
Control how much text a quantifier consumes: use +, *, ?, {n,m}, switch to lazy with a trailing ?, and predict the match the engine actually returns.
What you will learn
- Attach a quantifier to the right atom: ab+ repeats b, (ab)+ repeats the pair.
- Read greedy as: take everything, then give characters back until the rest fits.
- Make any quantifier lazy with a trailing ?, knowing it only changes trial order.
- Prefer [^>]+ over .+? when a delimiter must never be crossed.
Understanding Quantifiers and greedy against lazy matching
A quantifier repeats the single atom immediately to its left, which is why /ab+/ matches abbb while /(ab)+/ matches ababab. JavaScript gives you ? for zero or one, * for zero or more, + for one or more, and {n}, {n,}, {n,m} for explicit counts. Beyond that, everything about quantifiers comes down to one question: how many repetitions does the engine try, and in what order.
By default quantifiers are greedy: .+ first swallows every remaining character, then hands one character back at a time until the rest of the pattern can match. That giving-back step is backtracking, and it explains the classic result of /<.+>/ on a line holding two tags. The engine runs to the end of the string and retreats only as far as the last >, so a single match covers both tags. Greedy is not a bug; it means the longest viable possibility is the one you get.
Adding ? after a quantifier makes it lazy: .+? starts with the fewest allowed repetitions and grows one at a time. The set of strings the regex can match is identical, only the trial order flips, so the rest of the pattern still decides the outcome and /a+?b/ against aaab still consumes all three a characters. Laziness also never overrides position: JavaScript first finds the leftmost index where the whole pattern can match, and only then prefers fewer repetitions, which is why lazy does not mean shortest match in the string.
The sturdier fix is usually to remove the choice entirely. Replacing the dot with a negated class that cannot cross the delimiter, as in <[^>]+>, gives the same result with no backtracking at all.
const html = '<b>bold</b> and <i>italic</i>';
console.log(html.match(/<.+>/)[0]);
console.log(html.match(/<.+?>/)[0]);
console.log(JSON.stringify(html.match(/<[^>]+>/g)));
const price = 'total: 1299 cents';
console.log(price.match(/\d{2,3}/)[0]);
console.log(price.match(/\d{2,3}?/)[0]);Greedy and lazy quantifiers accept exactly the same strings; the trailing ? only changes the order in which repetition counts are tried, and the rest of the pattern decides which attempt wins.
Worked examples
Greedy .* glues two quoted values together
Shows how backtracking from the end of the string produces one oversized capture, and two ways to stop it.
const attrs = 'src="a.png" alt="cat"';
console.log(attrs.match(/"(.*)"/)[1]);
console.log(attrs.match(/"(.*?)"/)[1]);
console.log(attrs.match(/"(.*?)"/g).join(' | '));
console.log(attrs.match(/"([^"]*)"/g).join(' | '));Example explained
Line 1Greedy .* runs to the end of the string and gives characters back only until the final quote satisfies the closing ", so group 1 spans both attributes.
Line 2With .*? the engine tries zero characters first and grows one at a time, stopping at the first closing quote and capturing a.png.
Line 3match with /g returns whole matches and discards capture groups, which is why the quotes appear in the joined output.
Line 4[^"]* physically cannot cross a quote, so it reaches the same result with no backtracking to undo.
Lazy still expands when forced
Demonstrates that a trailing ? reorders attempts rather than limiting what the quantifier can eventually match.
const s = 'aaab';
console.log(/a+?/.exec(s)[0]);
console.log(/a+?b/.exec(s)[0]);
console.log(/a??b/.exec(s)[0]);
console.log(/a{2,}?b/.exec(s)[0]);Example explained
Line 1On its own, a+? is satisfied by a single character, so the match is just a.
Line 2Inside /a+?b/ the same quantifier is driven up to three repetitions because nothing else can put b next to it; the outcome equals the greedy one.
Line 3a?? means zero or one, prefer zero, so the match only succeeds from index 2 where b is directly reachable, giving ab.
Line 4{2,}? begins at the lower bound of two and adds one repetition per retry, ending at aaab.
* can match nothing at all
Illustrates the empty matches a star quantifier produces and how they leak into match and replace results.
const data = 'a1,,b223';
console.log(JSON.stringify(data.match(/\d*/g)));
console.log(JSON.stringify(data.match(/\d+/g)));
console.log(JSON.stringify(data.match(/\d{1,2}/g)));
console.log(data.replace(/\d*/g, '#'));Example explained
Line 1\d* succeeds by matching zero digits, so the global scan reports an empty match at every non-digit position plus one past the end.
Line 2\d+ demands at least one digit, which is what actually removes the empty results.
Line 3{1,2} caps each match at two digits, so 223 is reported as 22 followed by 3.
Line 4Those same empty matches make replace insert # wherever no digit starts, the usual symptom of writing * where + was meant.
Important notes
Lazy is not a performance fix. JavaScript has no atomic groups or possessive quantifiers, so a narrower character class, not a trailing ?, is the cure for runaway backtracking.
A trailing ? applies only to the quantifier right before it, and quantifiers cannot be stacked: /a+?/ and /a{2,}?/ are fine, while /a+??/ throws a SyntaxError because the extra ? has nothing to repeat.
Common mistakes
Using /<.+>/ to grab one tag: it returns everything from the first < to the last >, collapsing a whole line into a single match.
Treating the ? in a? and the ? in a+? as the same thing: the first adds an optional character, the second only reverses the order of attempts, so people wrongly conclude lazy matching is broken when /a+?b/ eats every a.
Reaching for * when + is meant: match(/\d*/g) returns empty strings at every non-digit position and replace inserts text there too.
Try it yourself
Change, predict, then run
Take const log = '[10:31] ERROR [10:32] WARN' and log log.match(/\[.+\]/g) to see the single oversized match. Then fix it twice, once with a lazy quantifier and once with a negated class, so both timestamps come back as separate array entries.
Open the JavaScript workspaceCheck your understanding
What does '<<a>>'.match(/<.+?>/)[0] evaluate to?
- <<a> because the earliest possible start wins, then the lazy part grows until > can match
- <a> because a lazy quantifier always yields the shortest match found anywhere in the string
- <<a>> because the trailing ? has no effect once the dot is able to match > as well
- <a>> because the engine skips the first < and then runs to the last >
Show answer
JavaScript scans left to right for the first index where the whole pattern can succeed, which is index 0, so the match must begin with the first <. Only then does laziness apply: .+? tries one character (<), finds a instead of >, and grows to <a> so that > matches at index 3, producing <<a>. Option 2 is tempting because lazy is often summarised as shortest, but shortest only ranks alternatives that start at the same position; returning <a> would mean abandoning a viable earlier start, which the engine never does.