I deployed a regex pattern I had copied from a StackOverflow answer for email validation. It worked fine in testing. Six days later, a user submitted a 60-character string that did not match the pattern. The Node.js event loop blocked for 8 seconds — the CPU hit 100% on a single request. Every API call timed out while the regex engine explored every possible character combination in that string. Understanding why this happens takes 15 minutes. Not understanding it cost me two hours of production downtime.
Test regex patterns locally — see matches in real time.
Interactive regex tester with flag support and match highlighting. Runs in your browser.
Open Regex Tester →How the Regex Engine Works
JavaScript uses a backtracking NFA (Nondeterministic Finite Automaton) regex engine. It processes patterns left-to-right, trying to match the input string. When it hits a choice point (multiple possible paths), it takes one path. If that path fails, it backs up to the last choice point and tries another.
This backtracking is usually imperceptible — a few thousand operations for typical strings. The problem is when the pattern structure allows exponentially many choice points for a given input length.
Regex Flags — What Each One Does
// JavaScript regex flags (can be combined)
const str = "Hello World
hello world";
// g — global: find all matches, not just the first
str.match(/hello/gi); // ['Hello', 'hello']
// i — case insensitive
str.match(/hello/i); // ['Hello']
// m — multiline: ^ and $ match start/end of EACH LINE
str.match(/^hello/mi); // ['Hello', 'hello'] — both line starts
// s — dotAll: . matches newline characters too
str.match(/Hello.World/s); // null — the
becomes matchable
// u — unicode: enables full Unicode character handling
/😀/u.test('😀'); // true — emoji matched via code point
// d — indices: captures match start/end positions (ES2022)
const result = /hello/di.exec(str);
result?.indices?.[0]; // [0, 5] — start and end index
// Common combinations:
const globalCaseInsensitive = /pattern/gi;
const multilineGlobal = /^line/mg;Common Patterns — Correctly Written
// Email — simplified, ReDoS-safe (no nested quantifiers) // Matches: [email protected] (basic format check only) const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/; // URL (matches http and https) const urlRegex = /^https?://[^s/$.?#].[^s]*$/i; // Phone number (E.164 international format) const phoneRegex = /^+[1-9]d{1,14}$/; // UUID v4 const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; // ISO 8601 date (YYYY-MM-DD) const dateRegex = /^d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]d|3[01])$/; // Hex colour (#RGB or #RRGGBB) const hexColourRegex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i; // Semver version (1.2.3 or 1.2.3-beta.1) const semverRegex = /^d+.d+.d+(-[w.]+)?(+[w.]+)?$/; // Slug (URL-safe string) const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; // Strong password (min 8 chars, uppercase, lowercase, number, symbol) const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*d)(?=.*[W_]).{8,}$/;
Named Capture Groups — Clean and Readable
// Named capture groups (ES2018+) — far cleaner than positional indices
const datePattern = /(?<year>d{4})-(?<month>0[1-9]|1[0-2])-(?<day>0[1-9]|[12]d|3[01])/;
const match = datePattern.exec('Invoice date: 2026-07-03');
if (match?.groups) {
const { year, month, day } = match.groups;
console.log(year); // "2026"
console.log(month); // "07"
console.log(day); // "03"
}
// vs positional (harder to read and maintain)
const positional = /(d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]d|3[01])/;
const m = positional.exec('2026-07-03');
// m[1] = year, m[2] = month, m[3] = day — easy to break if groups changeReDoS: Catastrophic Backtracking in Detail
The Pattern Structures That Cause It
// DANGEROUS: nested quantifiers — exponential backtracking const dangerous1 = /(a+)+$/; const dangerous2 = /(d+)*x/; const dangerous3 = /(a|a?)+$/; // Test: this input will hang the regex engine (never run this in production!) const maliciousInput = 'a'.repeat(30) + 'b'; // dangerous1.test(maliciousInput) → takes seconds to minutes → event loop blocked // Why: the engine tries 2^30 (1 billion+) combinations to prove no match
// SAFE alternatives — restructured to eliminate nested quantifiers
// Instead of /(a+)+$/ (matches one-or-more groups of one-or-more a's)
// Just use:
const safe1 = /^a+$/; // Matches one or more a's at start to end — same result, no nesting
// Instead of /(w+)*x/ (zero or more groups of word chars before x)
const safe2 = /w*x/; // Direct quantifier on w — no grouping overhead
// Instead of /(a|aa)+/ (ambiguous: can match 'a' or 'aa' per group)
const safe3 = /a+/; // Equivalent result, unambiguous
// Email example — the dangerous StackOverflow version vs safe version
const DANGEROUS_EMAIL = /^([a-zA-Z0-9])(([-.]|[_]+)?([a-zA-Z0-9]+))*(@){1}[a-z0-9]+[.]{1}(([a-z]{2,3})|([a-z]{2,3}[.]{1}[a-z]{2,3}))$/;
// ↑ Multiple nested quantifiers — ReDoS vulnerable
const SAFE_EMAIL = /^[^s@]+@[^s@]+.[^s@]+$/;
// ↑ No nested quantifiers — O(n) time complexityHow to Test for ReDoS Vulnerability
// Simple performance test — does execution time grow exponentially?
function testReDoS(pattern: RegExp, baseChar: string, lengths: number[]): void {
lengths.forEach(len => {
const input = baseChar.repeat(len) + 'X'; // 'X' ensures no match
const start = performance.now();
pattern.test(input);
const elapsed = performance.now() - start;
console.log(`Length ${len}: ${elapsed.toFixed(2)}ms`);
});
}
// Test a suspicious pattern
testReDoS(/(a+)+$/, 'a', [10, 15, 20, 25, 30]);
// Output:
// Length 10: 0.2ms
// Length 15: 6.4ms ← growing faster than linear
// Length 20: 200ms ← exponential growth confirmed
// Length 25: 6400ms ← dangerous!
// npm install safe-regex — static analysis alternative
// const safeRegex = require('safe-regex');
// safeRegex(/(a+)+$/) → false (unsafe)Lookahead and Lookbehind — Often Misused
// Lookahead: (?=...) asserts what follows WITHOUT consuming characters
// Lookbehind: (?<=...) asserts what precedes WITHOUT consuming characters
// Example: find "cat" only when followed by "fish"
const catfishPattern = /cat(?=fish)/;
catfishPattern.test('catfish'); // true
catfishPattern.test('catflap'); // false
// Negative lookahead: (?!...)
// Find digits NOT followed by 'px'
const notPx = /d+(?!px)/;
// Common mistake: using .* inside lookahead (can be slow)
const slow = /(?=.*[A-Z])(?=.*[0-9])/; // Fine for short strings, slows on long inputs
// Better: use anchored pattern for fixed-length password checks
const fast = /^(?=[^A-Z]*[A-Z])(?=[^0-9]*[0-9])/; // More specific — fasterBefore Deploying a Regex to Production
- 1Check for nested quantifiers:
(x+)+,(\w+)*, or alternation with overlap(a|ab)+are red flags. - 2Test with long non-matching inputs: if execution time grows exponentially with input length, the pattern has a ReDoS vulnerability.
- 3Use
safe-regexfor static analysis: catches many common vulnerable patterns without runtime testing. - 4Wrap regex in a timeout in server code: kill any regex evaluation that takes over 10ms. Worker threads with a timeout budget are the correct approach.
- 5Use named capture groups: maintainability matters.
match.groups.yearis less fragile thanmatch[1].
If you are working with JSON data that needs pattern-matching and transformation, our JSON data tools guide covers the tools for querying and transforming structured data beyond what regex should be doing. For the broader security context of tools that process user input without sending it to a server, our client-side tools guide explains the architecture.
Test regex patterns safely — in your browser.
Interactive regex tester with match highlighting, group capture display, and flag toggles.
Open Regex Tester →