DevKitLab Logo DevKitLab
Regex / Debugging / JavaScript

Why Isn't My Regex Matching? Start by Understanding the Engine

A regex that 'doesn't match' has usually matched — just not the span you expected. Instead of memorizing tips, understand how the engine walks the text and backtracks: greedy, lazy, anchors, lookahead, and catastrophic backtracking are all sides of that one mechanism.

You write a regex, stare at it for a while, and the longer you look the more certain you are that it should match. But it matches nothing — or it matches something wildly different from what you wanted. So you start piling things on: another .*, another \, one more set of parentheses around that group. Each addition nudges it further from correct.

Here’s the thing most regex tutorials won’t lead with: the engine is never wrong. It did exactly what you wrote, letter for letter — it’s just that what you wrote isn’t what you meant. Which makes “why isn’t it matching?” the wrong question. The more useful one is: where is the engine right now, what did it try, and why did it stop there? Once you can think the way the engine does, every baffling result collapses into something predictable.

This article isn’t a list of tips. It spends a few minutes on how the engine works, then evolves a single example all the way through — from a pattern that’s wildly wrong to one that’s correct, robust, and won’t tank your performance. Greedy vs. lazy, the flag you forgot to turn on, the lastIndex gotcha that makes results flip-flop, anchors, the two worlds of escaping, zero-width lookahead, the three layers of Unicode, and finally the catastrophic backtracking that can peg your CPU — you’ll see they’re all facets of the same machine.

What the engine is actually doing

JavaScript — like PCRE, Python’s re, Java, and most engines you touch day to day — is a backtracking engine. The way it works is honestly pretty simple: the engine keeps a “current position,” starts at the far left of the string, and reads one character at a time moving right, trying at each position to see whether your pattern can match starting from there.

The whole game is in that word try. Every time the engine hits a quantifier like *, +, or ?, it faces a choice: eat one more character, or stop here? A greedy quantifier (the default * and +) always chooses “eat more,” swallowing as much as it can — while noting down every “I could have stopped here” position on a backtracking stack. When the pattern later gets stuck somewhere down the line, the engine doesn’t give up immediately; it rewinds to the most recently noted position, spits one character back out, and tries a different way forward. Only when it has exhausted every possibility at the current position does it concede, shift the current position one to the right, and start over from scratch.

This one move — eat greedily, and when you get stuck, backtrack by spitting back out — is the master key to reading regex behavior. You’ll see it again and again below: greedy overshoots because it swallows first and gives back later; lazy does the opposite because it holds off until forced; lookahead doesn’t consume characters because it only peeks and doesn’t actually advance; catastrophic backtracking pegs the CPU because the number of “try a different way” combinations explodes exponentially. One machine, one set of rules.

Worth knowing in passing: there’s a different class of engine that doesn’t backtrack — like Go’s regexp and Rust’s regex (the one behind ripgrep). They’re built on finite automata that turn matching into a single linear scan, which makes them immune to catastrophic backtracking by construction — at the cost of dropping features like backreferences that inherently need backtracking. That explains a common puzzle: the same dangerous pattern runs blazing fast under ripgrep yet can hang a Node.js process. Which class your engine is in decides which pitfalls even apply to you. This article is about JavaScript’s backtracking engine.

The string we’re fixing

The whole article aims at one target, and it’s close to something real — two links sitting side by side:

<a href="/products/12">Boots</a> <a href="/products/34">Hat</a>

The goal is modest: pull out both href values, i.e. /products/12 and /products/34. It’s simple enough that you’d expect to nail it in one shot, yet it manages to draw out every quirk of the engine above. Before touching anything, remember one thing: the first step in debugging a regex is never to change the pattern — it’s to see where it currently lands. Put the pattern, the flags, and the test text together and let it highlight each matched span right on the original — that’s exactly what the regex tester is for. It turns an abstract pattern into a stretch of colored text your eyes can read. In every section below, you can paste that section’s pattern in and see with your own eyes which span it actually matched.

Greedy: it didn’t fail to match, it backtracked too late

Here’s a first version almost everyone has written:

<a href="(.*)">

You expect (.*) to capture /products/12. What it actually captures is:

/products/12">Boots</a> <a href="/products/34

From just after the first href=", it eats all the way to just before the last ">. Through the engine’s lens, this is no mystery at all: .* greedily swallowed everything from here to the end of the line in one gulp, then the engine noticed the pattern still owed one ">, so it began backtracking — spitting characters back out one at a time, from the right edge leftward. By the time it had backed up to the last ">, the pattern was satisfied and declared success, so it stopped there and never gave anything more back. It didn’t “want to eat that much” — it backtracked too late to hit the first place it could close.

The fix is to add a ? to the quantifier, flipping it from greedy to lazy:

<a href="(.*?)">

.*? reverses the default choice — at each step it first picks “don’t eat,” and only grudgingly swallows one character when the next step won’t work otherwise. So it starts from empty, expands a little at a time, and stops at the first ">, leaving (.*?) capturing exactly /products/12. Paste both patterns into the regex tester in turn and you’ll watch the highlight snap back — the most direct look you’ll get at greedy vs. lazy.

That said, the cleanest version is neither greedy nor lazy — it’s the one that never gives the engine room to backtrack in the first place:

<a href="([^"]*)">

[^"]* means “any character except a double quote.” It can’t reach that closing quote to begin with, so it naturally stops right before it — no eating too much and giving it back, no backtracking at all. That’s not just faster; it’s safer. And here’s a JavaScript hard limit worth knowing early: some engines offer atomic groups (?>...) or possessive quantifiers a++ that explicitly tell the engine “once you’ve eaten this, never give it back,” locking out backtracking at the root. JavaScript to this day has neither. So in JS, the only lever you have over backtracking is exactly this — rewrite the pattern into a shape with no ambiguity, using a negated character class to nail down the boundary instead of leaning on .*?. Hold onto this; it’s the cure when we get to catastrophic backtracking.

Flags and state: the pattern is fine, it’s the engine’s switches and memory

Say you’ve moved to <a href="([^"]*)">, but it only matches the first link; the second, /products/34, refuses to show up. Nothing’s wrong with the pattern — the problem is a flag: one of those switches that default to off, don’t complain when you forget them, and quietly hand you half a result.

  • g (global) — it makes replace handle every hit, and it’s the prerequisite for matchAll. It also changes what String.prototype.match() returns: without g you get the first match plus its capture groups; with g you get all the full matches. To iterate every hit and its groups reliably, matchAll is the clearest option. Your only match above was the first because g was off.
  • i (ignore case)/hat/ won’t match Hat; turn on i and HAT, Hat, hat are all equal.
  • m (multiline) — it only changes what ^ and $ mean; next section.
  • s (dotall) — by default . does not match a newline. If your pattern has a .* and the content spans lines, . stops at the newline; turn on s and it treats newlines as matchable too.

That’s all still common knowledge. But g hides a deeper trap: a regex with g (or y) is stateful. The same RegExp object remembers a lastIndex — where the previous match ended — and picks up from there next time. Feed the same object to .test() or .exec() repeatedly and you get this seemingly impossible result:

const re = /href/g;
re.test('href');  // true —— lastIndex advances to 4
re.test('href');  // false —— starts looking from position 4, nothing there
re.test('href');  // true —— hit the end, lastIndex resets to 0, starts over

Same string, same regex, yet test flip-flops between true and false. The culprit is that silently rewritten lastIndex. Reusing a g regex isn’t wrong in itself — that’s exactly how you’d deliberately walk matches with exec() — but before a .test() check that doesn’t need to continue from a position, reset lastIndex = 0 or build a fresh regex. Above all, don’t hoist it into a module-level constant and then test it all over the place. To read every match with its groups, prefer String.prototype.matchAll(); it derives an iterator from the original and never touches that object’s lastIndex. One aside: this site’s regex tester enumerates every hit in its matches list for readability, while the replace preview still honors whether you’ve turned on g.

Anchors and boundaries: they match a position, not a character

Anchors are a special case in the backtracking model — they’re zero-width, matching the seam between characters rather than a character itself. That’s where they’re most counterintuitive.

^ and $ anchor to the start and end of the whole string by default, not each line. Take this multiline text:

error: disk full
error: timeout

You write ^error: hoping to match the start of each line, but by default ^ only recognizes that one position at the very start of the whole string, so only the first line hits. To make ^ and $ fire at the start and end of every line, turn on m (multiline). These two almost always come as a pair; forgetting m is the number one reason for “why does it only match the first line?”

\b (word boundary) is another commonly misread zero-width assertion. It matches the seam “between a word character and a non-word character.” \bcat\b captures cat in a cat sat, but won’t match the cat in category — because cat is immediately followed by e, and there’s no boundary there. Plenty of people treat \b as “a space,” but punctuation and the start/end of a line count as boundaries too. And there’s a deeper trap: \b decides “what counts as a word character” using the ASCII definition ([A-Za-z0-9_]), even when you’ve turned on u. So for Chinese, or accented Latin letters, \b will disagree with your intuition — a Han character isn’t a “word character” in its eyes, so the boundary lands in the wrong place.

Escaping: one dot, two worlds

In a regex, . + * ? ( ) [ ] { } ^ $ | \ all carry special meaning; to match them literally you have to escape them with \. The most common victim is the dot: you want to match the . in a version number, so you write \d+\.\d+; get lazy and write \d+.\d+, and that bare . becomes “any character,” so 1X2, 1 2, 1a2 all match — far looser than you wanted, with no error, quietly matching things it shouldn’t the day some dirty data comes through.

But what really drives people up the wall is the two worlds of escaping. A tester’s input box takes the raw regex, where one backslash is one \; but a regex in code often lives inside a string, and the string itself also uses \ for escaping, so the same pattern is written differently in the two worlds:

// In a regex literal, one backslash is enough:
/\d+\.\d+/

// But built from a string, the backslashes double — the string eats one layer first:
new RegExp('\\d+\\.\\d+')

“It works fine in the tester and breaks the moment I put it in code” is, nine times out of ten, this layer of backslashes not lining up — the \\d you copied out of some JSON or a log field should actually be \d when pasted into the tester, and doubled back up when returned to a code string. The regex tester’s export feature handles that difference for you, giving you the paste-ready form in the target language and sparing you the manual backslash-counting.

There’s an advanced case too: when you build a regex from a variable — say, treating user input as a literal string to match — any ., (, or ? in that input gets read as a metacharacter, which at best matches wrong and at worst lets external input rewrite your matching logic. RegExp.escape() was standardized in ES2025 and safely turns a string into a literal-match fragment; modern browsers support it, but if you need to support older browsers or runtimes, check your target environment first and reach for a maintained polyfill if necessary. When you build a regex dynamically, the variable part must be escaped first — never drop an external string straight into the pattern.

Lookahead and lookbehind: they check a condition, they don’t consume characters

At this point you might want something more precise: pull out the href value without the surrounding quotes. Lookahead and lookbehind exist for exactly this, and they carry a misconception almost everyone falls for.

Back to the engine model: a lookahead (?=...) makes the engine “peek forward” to check whether what follows matches some pattern — but after that peek, the current position does not move forward. It’s zero-width: whether the condition holds affects success or failure, but the characters it checks don’t count toward the match result. So:

\d+(?=px)

For 12px, it matches 12, not including px. If you print match[0] and find no px in it, that’s not a bug — that’s the definition of lookahead. Likewise negative lookahead (?!...), lookbehind (?<=...), and negative lookbehind (?<!...) are all zero-width — they frame the context without consuming it.

This “doesn’t take up characters” property is exactly what makes them valuable. To pull out the value between the quotes without the quotes, you can write:

(?<=href=")[^"]*(?=")

The lookbehind asserts “I’m preceded by href=",” the lookahead asserts “I’m followed by ",” and the [^"]* in the middle is the only part actually consumed and returned — the quotes are conditions only, not results, sparing you a later slice on both ends. Paste it into the regex tester and you’ll see it vividly: the condition regions aren’t highlighted, only the actually-consumed span is colored — the abstract word “zero-width” made concrete at a glance. (One JavaScript bonus: its lookbehind supports variable-length patterns, unlike some engines that allow only fixed-length lookbehind, so (?<=\w+=") is valid in JS.)

The three layers of Unicode: ASCII, code points, grapheme clusters

The moment your text contains Chinese, emoji, or any non-ASCII character, a whole layer of pitfalls is waiting — and it’s deeper than most people assume. “Unicode support” actually comes in three layers, and every one of them can bite you.

Layer one: stop assuming \d and \w know about non-ASCII. In JavaScript, \d matches only ASCII 0-9 by default and \w recognizes only [A-Za-z0-9_], so \w+ can’t capture a single Han character. To match by Unicode category, turn on the u flag and switch to \p{...} property escapes: \p{L} is a letter in any language, \p{N} is a number, \p{Han} is Han characters specifically, and /\p{L}+/u can finally match a Chinese word. Here’s the deeply counterintuitive detail: turning on u won’t make \d, \w, or \b understand Unicode — they stay ASCII-semantic. To match “any digit,” including full-width and Arabic-Indic ones, you have to write \p{Nd}, not hope that u upgrades \d.

Layer two: u makes the engine walk by code point rather than UTF-16 code unit. Without u, a character that occupies two code units (like many emoji) gets sliced in half by ., and what you cut out is garbage. With u, . handles complete code points, and a notation like \u{1F600} is finally recognized.

Layer three, the one most often overlooked: a code point still isn’t “one character.” What the eye sees as a single character is technically a grapheme cluster, which can be assembled from several code points — a skin-toned emoji, a flag, a family emoji like 👨‍👩‍👧 glued together with zero-width joiners, are all combinations of multiple code points. Even with u on, . matches only one of those code points, so you can still shred an emoji. To handle things as “characters the way people see them,” there are two roads: ES2024’s v flag (unicodeSets) brings set operations and string properties like \p{RGI_Emoji} that can match a composite emoji as a whole; and for general grapheme segmentation, use Intl.Segmenter — it’s not a regex, but it’s the better fit for splitting text along human perception. This site’s tester currently offers u and not a v toggle; if you need v, verify it in your target environment. When you work with emoji or multilingual text, decide up front which layer you want — it saves you a whole class of “matched half a character” problems.

It matches correctly, yet freezes the page: catastrophic backtracking

The last kind of “wrong” isn’t a wrong match — it’s the match hanging the moment it runs: the tab goes unresponsive, the CPU pegs. This is catastrophic backtracking, and it’s the mechanism behind ReDoS denial-of-service attacks. With the engine model from earlier, you can now understand it from first principles.

The classic form is nested quantifiers:

(a+)+$

Run it against a string of aaaaaaaaaaX (whose ending is deliberately not what it wants). The inner a+ and outer + can match the same batch of characters, so that run of a’s has an exponential number of ways to be split into groups: (a)(a)(a)…, (aa)(a)…, (a)(aa)…, and so on — and that trailing $ can never hold before X, forcing the engine to backtrack through every single split before it dares declare failure. Make the input a little longer and the time it takes to fail climbs steeply; exactly how long depends on the browser, hardware, and engine implementation, so you can’t pin it to a fixed character count. The culprit’s profile is fixed: one quantifier wrapped around another, where both can match the same batch of characters(a+)+, (.*)*, (\d+)* are all in this family; (.*?)*, nesting lazy inside greedy, is just as dangerous.

This is exactly the payoff of that earlier setup: why, back at greedy, I urged you to use [^"]* rather than .*?. A negated character class draws the boundary up front, with no need to probe back and forth across every position. JavaScript currently has no atomic groups or possessive quantifiers — the two syntaxes that directly cap backtracking — so the go-to move is to rewrite the pattern to remove the ambiguity: keep each branch’s match range from overlapping, add anchors at the right spots, swap the boundless .* for an explicit character class. This site’s regex tester checks for potential ReDoS risk asynchronously; when it detects risk it shows an attack sample, and when it can’t complete the judgment it says so clearly. It’s good for catching problems early, but it’s no substitute for a performance test at real input scale.

A checklist you can actually follow

Next time a regex misbehaves, don’t rush to pile on characters. Go back to the engine’s point of view and run this order — it locates the problem almost every time:

  1. First see where it currently matches. Paste the pattern and text into the regex tester and look at the highlight; don’t guess. Eight times out of ten the problem shows itself right here.
  2. Overshooting? A greedy .* backtracks too late — change it to .*?, or better, the negated character class [^…]*.
  3. Only matching the first one / first line? You probably forgot to turn on g or m.
  4. Same code, works then doesn’t? Check whether you’re reusing a g regex and lastIndex is at play.
  5. . ( [ not matching literally? Escape what needs escaping with \, and make sure the backslashes weren’t eaten or doubled at the string layer.
  6. Result has more or less than you expected? Consider whether you’ve treated a zero-width lookahead/lookbehind as an ordinary group.
  7. Chinese or emoji not matching, or getting shredded? Separate the three layers — ASCII, code point, grapheme cluster: turn on u, use \p{...}, and reach for the v flag or Intl.Segmenter when needed.
  8. Hangs the moment it runs? Look for nested quantifiers — that’s catastrophic backtracking, solved in JS by rewriting the pattern.

Getting the regex right is often where the work begins. Batch-replacing what you matched into some other shape is what the text replacer does with regex capture groups; confirming exactly what changed between two versions of text is a job for the text comparison tool. But every step starts from the same place — a regex whose matches, and the reason for each step it takes, you can see clearly — not one you can only stare at and guess about.