Developer
Regular expressions in practice: patterns, flags and the traps that bite
Most regex bugs are not syntax errors. The pattern compiles, matches the sample you tried, and then misbehaves on the second call, on a string with a newline, on an emoji, or on a 30-character input that takes seconds to reject. This guide covers the JavaScript RegExp behaviors behind those failures, shows real output for each, and says where a regular expression should not be used at all. It is written for anyone who writes patterns in JavaScript or TypeScript, or who tests them in a browser before pasting them into code.
By the MaffeiTools team · Updated · 9 min read
Which engine the tester runs, and which flags it exposes
The regex tester on this site does not ship its own matcher. It calls new RegExp(pattern, flags) in your browser, so what you see is what ECMAScript-compliant JavaScript will do. Syntax from other flavors does not carry over. In Bun, a possessive quantifier such as a++ fails with nothing to repeat, and an atomic group (?>a) or a leading inline modifier (?i) fails with an unrecognized character after (?. The anchor \A is worse, because without the u flag it is accepted and silently matches the letter A.
The tool offers six flag toggles, and only g is on when it opens. The sticky flag y and the set-notation flag v are not offered, though both exist in the language. The test string is cut to its first 50,000 characters, and with g the match loop stops after 10,000 matches.
- g finds every match instead of the first, and makes the regex stateful (covered below).
- i ignores case. With u it uses Unicode case folding, without it a simpler mapping.
- m makes ^ and $ match at line boundaries as well as at the string ends.
- s lets the dot match line terminators.
- u switches on full Unicode code point handling, Unicode property escapes, and stricter syntax.
- d records start and end indices for every group. The tool already reports positions, so d changes little there.
Anchors and the dot both stop at line breaks unless told otherwise
Without the m flag, ^ matches only at the very start of the input and $ only at the very end. A pattern like ^beta$ will never find the middle line of a three-line string. With m, both anchors also match next to a line terminator. That is the flag to reach for when validating each line of a pasted list, and the wrong flag when validating a whole field, because a multiline input such as one valid line followed by junk can then pass an anchored check.
The dot has the mirror-image rule. It matches any single character except a line terminator, and the ECMAScript definition of that set includes carriage return and the Unicode line and paragraph separators, not just newline. To let one pattern span lines you either add the s flag or write [\s\S], which works in every engine and reads as an explicit choice.
const text = "alpha\nbeta\ngamma"
/^beta$/.test(text) false
/^beta$/m.test(text) true
/a.b/.test("a\nb") false
/a.b/s.test("a\nb") trueGreedy quantifiers take too much; negated classes take exactly enough
The quantifiers *, +, ? and {n,m} are greedy: they consume as much as they can, then give characters back one at a time until the rest of the pattern matches. Appending ? makes them lazy, which reverses the order: consume the minimum, extend only if the rest fails.
Lazy matching is the usual fix, but it is often not the best one. Writing the delimiter into a negated character class states the intent directly and is also cheaper for the engine, because there is no back-and-forth. Here [^<]* means any run of characters that is not the start of the next tag.
const html = "<b>one</b> and <b>two</b>" /<b>.*<\/b>/ "<b>one</b> and <b>two</b>" /<b>.*?<\/b>/ "<b>one</b>" /<b>[^<]*<\/b>/ "<b>one</b>"
Groups and lookaround: what each construct captures and what it consumes
Parentheses do two jobs: they group for quantifiers and alternation, and they capture. When you only need the grouping, write (?:...). That keeps the numbered results stable when someone later adds a group at the front, and it avoids a side effect in String.prototype.split, which splices every capture group into its output.
Named groups, written with the name in angle brackets right after the opening (?, come back on the groups property of the match and can be referenced in a replacement by the same name in angle brackets after a dollar sign. They cost nothing at match time and remove the counting errors that come with $1, $2 and $3. The find-and-replace tool understands $1, named references, $& and $$ in regex mode, and treats the replacement box as literal text in plain mode.
A lookaround checks what surrounds a position and leaves the position where it was. Lookahead is (?=...) and (?!...); lookbehind is (?<=...) and (?<!...). Because nothing is consumed, the match text excludes the context, which is why lookbehind is the clean way to grab a number after a currency symbol without capturing the symbol.
The best-known use is inserting thousands separators: find every position that is followed by one or more complete groups of three digits and then a non-digit, and insert a comma there. Lookbehind is supported by current browsers but is younger than lookahead, so check your target runtimes before shipping a pattern that depends on it to older devices.
"a1b2c".split(/(\d)/) ["a","1","b","2","c"]
"a1b2c".split(/\d/) ["a","b","c"]
"ab ab".match(/(ab) (ab)/) captures: 2
"ab ab".match(/(?:ab) (ab)/) captures: 1
findAndReplace, regex mode:
find (?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})
replace $<d>.$<m>.$<y>
"Released 2026-09-19" becomes "Released 19.09.2026"
"$10 and 20 EUR".match(/(?<=\$)\d+/g) ["10"]
"foobar foobaz".search(/foo(?=baz)/) 7
"1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ",") 1,234,567A global regex remembers where it stopped
A regex created with g or y keeps a writable lastIndex property. Each call to exec or test begins searching there, and on success moves it past the match; on failure it resets to 0. The consequence is that calling test twice on the same string with the same global regex object can give different answers. MDN documents this and advises resetting lastIndex after each input has been fully processed.
The bug appears most often when a regex literal is hoisted into a module-level constant and reused inside a filter callback. Every second matching item is then silently skipped. The fixes are to drop the g flag when you only need a boolean, to build the regex inside the function, or to set lastIndex to 0 before each test. String.prototype.match and replaceAll with a global regex reset the state themselves, so the trap is specific to test and exec.
const re = /cat/g
console.log(re.test("cat"), re.lastIndex) true 3
console.log(re.test("cat"), re.lastIndex) false 0
console.log(re.test("cat"), re.lastIndex) true 3The u and v flags: code points, property escapes and stricter syntax
Without u, JavaScript strings are matched as UTF-16 code units. An emoji outside the Basic Multilingual Plane is two units, so a lone dot matches half of it and the string has length 2. With u the same dot matches the whole code point. The shorthand \w stays ASCII-only either way, so it cuts "café" at the accent; \p{L} with u matches letters in any script, and forms like \p{Script=Greek} select by script.
The u flag also makes the parser strict. An unnecessary escape such as \- outside a class is a syntax error under u and legal without it. The find-and-replace tool builds its regex with u first and, if construction fails, retries without u, so a legacy pattern still works there even though it would be rejected in strict mode.
The v flag, an upgrade to u that has been available across browsers since September 2023 according to MDN, adds set operations inside classes: subtraction with --, intersection with &&, and nested classes. u and v cannot be combined. The regex tester does not offer v, so test such patterns in code.
"😀".length, /^.$/.test("😀"), /^.$/u.test("😀") 2 false true
"café".match(/\w+/g) ["caf"]
"café".match(/\p{L}+/gu) ["café"]
"aÉb".match(/[\p{L}--[a-z]]/v)[0] É
"hello".match(/[[a-z]&&[^aeiou]]/gv) ["h","l","l"]Catastrophic backtracking: when a failed match is the expensive one
JavaScript engines match by backtracking. When a branch fails they return to the last choice point and try the next option. If a pattern lets the same characters be split among several quantifiers in many ways, and the overall match then fails, the engine tries every split. OWASP describes the classic case: ^(a+)+$ on a string of a's followed by one other character has 65,536 paths at sixteen a's, and the count doubles for each additional a.
The timings below show the doubling on a small ladder of sizes. Each extra pair of characters multiplies the time by roughly four. The absolute numbers depend on the engine and the machine, and the ladder stops at 26 on purpose; extrapolating the doubling puts 40 characters near an hour. The safe form states the same language without nested repetition.
Warning signs, from the OWASP list: a group that is itself repeated and contains a repetition, or alternation branches that can match the same text, as in (a|aa)+$. Defenses are to remove the nesting, to anchor and bound the pattern, and to cap input length before matching. The regex tester runs on the page's main thread with no time limit, so a pattern like this on a long non-matching sample can stall the tab until the engine finishes.
n=16 (a+)+$: 0.2 ms a+$: 0.003 ms n=18 (a+)+$: 0.9 ms a+$: 0.000 ms n=20 (a+)+$: 3.5 ms a+$: 0.001 ms n=22 (a+)+$: 14.2 ms a+$: 0.000 ms n=24 (a+)+$: 54.4 ms a+$: 0.002 ms n=26 (a+)+$: 222.4 ms a+$: 0.008 ms
Worth knowing. A Node.js-style server runs JavaScript on one thread per process, so a single vulnerable pattern applied to attacker-chosen text blocks every other request in that process until it finishes. Treat any pattern that runs on untrusted input as a security surface.
Why email and HTML validation belong outside regex
The HTML Standard is blunt about email: its definition of a valid email address is a deliberate, willful violation of RFC 5322, which it calls simultaneously too strict, too vague and too lax to be practical. It ships a regular expression for that definition, and that pattern is already a compromise. Run against samples, it accepts a@b with no dot in the domain and ann..x@example.com with a double dot, and rejects ann@münchen.de until the domain is converted to its punycode form. Nothing in the text can tell you that the mailbox exists. Confirmation by sending a message is the only complete check.
Extraction is a different job from validation, and looser patterns are correct for it. Even so, the email extractor here uses an unanchored pattern with a fixed character set, and it is deliberately pragmatic rather than a full RFC 5322 parser. The local part may contain letters, digits, dots, underscores, hyphens, percent signs and plus signs, so ann+news@example.com is reported whole, and the final label may be any run of two or more letters, so b.technology is reported whole. Quoted local parts and comments are not recognized. The URL extractor cuts a match at whitespace, then trims trailing punctuation and quotes and any closing bracket that was not opened inside the link, while keeping balanced parentheses, so a Wikipedia-style address survives. Read extractor output as candidates to review.
HTML has the same problem in a different form. Markup nests to arbitrary depth, and a regular expression has no way to count open elements. A greedy pattern grabs too much, a lazy one stops at the first close tag of the wrong element, and attributes containing angle brackets break both. Use the DOM parser the platform gives you for structure, and keep regex for the flat leaf text you extract from it.
HTML Standard pattern (valid email address), test(): ann@example.com true a@b true ann..x@example.com true ann@münchen.de false ann@xn--mnchen-3ya.de true extractEmails, ann+news@example.com and bob@b.technology: ["ann+news@example.com","bob@b.technology"] extractUrls, "See https://example.com/a, and (https://x.org/b).": ["https://example.com/a","https://x.org/b"] extractUrls, "Read https://en.wikipedia.org/wiki/Bar_(disambiguation).": ["https://en.wikipedia.org/wiki/Bar_(disambiguation)"]
Escaping text that is not meant to be a pattern
Whenever a string that came from a user, a file or another system is used as part of a pattern, it has to be escaped, or the characters . * + ? ^ $ { } ( ) | [ ] and the backslash will be read as syntax. Two things go wrong. An unescaped dot matches any character, so a search for 5.00 also finds 5x00. An unbalanced parenthesis makes the constructor throw, which takes down the code path if nothing catches the exception.
The find-and-replace tool has two modes for exactly this reason. In plain mode it escapes the needle before building the regex, in regex mode it uses your text as written and reports a syntax error as a message instead of throwing. Recent engines also provide RegExp.escape as a built-in. MDN lists it as newly available since May 2025, so a small helper is still the portable choice for older browsers.
src = "Total (USD) 5.00, code 5x00"
plain mode, find 5.00, replace 5,00 "Total (USD) 5,00, code 5x00"
regex mode, find 5.00, replace 5,00 "Total (USD) 5,00, code 5,00"
escapeRegExp("5.00 (USD)") 5\.00 \(USD\)
regex mode, find "(" Invalid regular expression: missing )