Regex Performance: How to Write Fast Regular Expressions

May 26, 202611 min read

Why Regex Performance Matters

Regular expressions can be deceptively simple. A regex that works perfectly on a 10-character string might take seconds — or even years — to process a 100-character string. This isn't hyperbole; poorly written regexes can exhibit exponential time complexity, causing application hangs, timeout errors, and even denial-of-service vulnerabilities (ReDoS — Regular Expression Denial of Service).

Understanding regex performance is crucial when:

  • Processing user input in web forms
  • Validating large documents or log files
  • Running regexes in hot loops
  • Building search functionality
  • Implementing security filters

The Root Cause: Catastrophic Backtracking

Most regex engines use a backtracking algorithm. When a match fails, the engine "backs up" and tries alternative paths. In poorly written regexes, the number of possible paths can grow exponentially.

A Classic Example

Consider this regex designed to match strings wrapped in < >:

<.*>

Seems innocent, right? Let's test it against <html>:

  1. < matches <
  2. .* greedily matches html>
  3. > tries to match > but fails (already consumed)
  4. Backtrack: .* gives up one character, now matches html
  5. > tries to match > — succeeds!
  6. Match found: <html>

That worked fine. Now try <html><body>:

  1. < matches <
  2. .* greedily matches html><body>
  3. > fails
  4. Backtrack repeatedly...
  5. Eventually .* matches html>
  6. > matches < — wait, no!
  7. More backtracking...
  8. Eventually matches <html><body>

Still okay. But what about <html><body><div>?

The backtracking steps grow. With nested structures, .* tries every possible split point.

The Catastrophic Case

Here's a regex that's notorious for causing ReDoS:

(a+)+$

Testing against aaaaaaaaaaaaaaaaaaaaaaaaaaaaX:

  1. a+ matches all the as
  2. The outer (a+)+ tries to capture another group — fails (no more as)
  3. Backtrack: first a+ gives up one a, second a+ takes it
  4. Try third group... fails
  5. Backtrack more...
  6. The number of permutations is 2^n where n is the number of as!

Result: 30 as followed by X can take billions of steps.

Visualizing the Problem

Think of backtracking like exploring a maze:

  • Each character is a decision point
  • The engine tries one path, then backtracks to try alternatives
  • Poor regex = exponentially many paths

Greedy vs Lazy Quantifiers

Greedy (Default Behavior)

Greedy quantifiers (*, +, ?, {n,m}) try to match as much as possible before backtracking.

const text = '<div>Hello</div><div>World</div>';

// Greedy: matches from first < to last >
text.match(/<.*>/);
// Result: '<div>Hello</div><div>World</div>'
// Steps: <.*> matches all, then backtracks to find last >

Lazy (Non-Greedy)

Adding ? after a quantifier makes it lazy — match as little as possible.

const text = '<div>Hello</div><div>World</div>';

// Lazy: matches from first < to first >
text.match(/<.*?>/);
// Result: '<div>'
// Steps: <.*?> matches minimally, then immediately tries >

Performance Comparison

PatternTextResultBacktracking
<.*><a><b><a><b>High (many steps)
<.*?><a><b><a>Low (stops early)

Rule of thumb: If you know what you're looking for (e.g., closing tag), use lazy quantifiers or be more specific.

// Bad: greedy catch-all
/<.*>/;

// Better: specific character class
/<[^>]*>/;

// Best: use a parser for HTML/XML!

Possessive Quantifiers and Atomic Groups

Some regex engines (Java, PCRE, .NET) support possessive quantifiers that never backtrack.

Possessive Quantifiers (Java, PCRE)

a++       # Possessive + (never gives up match)
.*+       # Possessive * (never backtracks)

Atomic Groups (More Widely Supported)

Atomic groups (?>...) prevent backtracking into the group.

(?>a+)b   # Once a+ matches, those a's are locked in

Why this helps: If the b fails to match, the engine won't backtrack into the (?>a+) to try fewer as.

JavaScript Workaround

JavaScript doesn't support atomic groups natively, but you can simulate with lookaheads:

// Instead of (?>a+)b
(?=(a+))\1b

// The lookahead matches and captures, then \1 references the capture
// Since lookahead doesn't consume, backtracking into it is limited

Common Performance Traps and Fixes

Trap 1: Nested Quantifiers

# BAD: Catastrophic backtracking
(a+)+
(.*)*
(.+)+

# BETTER: Use specific counts or single quantifiers
a+
.* 
.+

Trap 2: Overly Broad Character Classes

# BAD: .* matches too much
<.*>

# BETTER: Match only what you expect
<[^>]*>

Trap 3: Alternations with Overlapping Prefixes

# BAD: Both alternatives start with a
(aa|aaa)

# BETTER: Factor out common prefix
a(a|aa)

Trap 4: Backtracking Across Anchors

# BAD: ^ and $ are checked repeatedly during backtracking
^.*$

# BETTER: More specific
^[^\n]*$

Trap 5: Using Regex for Parsing Structured Data

// BAD: Trying to parse HTML/XML/JSON with regex
const html = '<div class="a"><span>b</span></div>';
const match = html.match(/<div.*?>(.*?)<\/div>/);

// BETTER: Use a proper parser
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const div = doc.querySelector('div');

Benchmarking Your Regexes

Simple JavaScript Benchmark

function benchmark(regex, string, iterations = 10000) {
  const start = performance.now();
  for (let i = 0; i < iterations; i++) {
    regex.test(string);
  }
  const end = performance.now();
  return end - start;
}

// Test with a problematic string
const regex = /(a+)+$/;
const goodString = 'aaaaa';
const badString = 'aaaaaaaaaaaaaaaaX';

console.log('Good string:', benchmark(regex, goodString), 'ms');
console.log('Bad string:', benchmark(regex, badString), 'ms');
// The bad string might take WAY longer!

Online Tools

Optimization Checklist

When writing regexes, check each item:

  • Avoid nested quantifiers like (a+)+ or (.*)*
  • Use specific character classes instead of . when possible
  • Prefer lazy quantifiers when matching until a known delimiter
  • Anchor your patterns with ^ and $ when matching full strings
  • Factor out common prefixes in alternations
  • Test with long, malicious strings to catch ReDoS
  • Consider atomic groups if your engine supports them
  • Don't use regex for parsing HTML, XML, or other nested structures

Real-World Examples

Example 1: Email Validation

// BAD: Overly complex, potential backtracking
const badEmailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

// GOOD: Same thing, but recognize it's already fairly optimal
// The character classes are specific, no nested quantifiers
const goodEmailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

// BETTER: If you control the input, simpler might suffice
const simplerEmailRegex = /^\S+@\S+\.\S+$/;

Example 2: URL Path Matching

// BAD: Greedy catch-all
const badPathRegex = /^\/api\/.*\/details$/;

// GOOD: Specific character class
const goodPathRegex = /^\/api\/[^\/]+\/details$/;

// Even better: exact segment count
const exactPathRegex = /^\/api\/([^\/]+)\/details$/;

Example 3: Log Parsing

// BAD: Nested quantifiers
const badLogRegex = /(\d+:)+(.+)/;

// GOOD: Specific format
const goodLogRegex = /^(\d{1,3}:)+\s+(.+)$/;

// BETTER: Parse known format
const logLine = '123:456:789 Some message';
const parts = logLine.split(':');
const message = parts.pop(); // "789 Some message"

Understanding Your Regex Engine

Different engines have different performance characteristics:

EngineUsed ByFeatures
RE2Go, Rust (regex crate)Linear time, no backtracking, no ReDoS
PCREPHP, ApacheBacktracking, supports atomic groups
ICUJava (some APIs)Backtracking with optimizations
ECMAScriptJavaScriptBacktracking, no atomic groups

Key insight: If you're using JavaScript, you're stuck with backtracking. Write your regexes accordingly!

Summary

Regex performance boils down to controlling backtracking:

  1. Avoid nested quantifiers — the #1 cause of ReDoS
  2. Be specific — use [^>]* instead of .* when possible
  3. Use lazy quantifiers for "match until X" patterns
  4. Benchmark with bad input — test with strings designed to fail
  5. Know your engine — RE2 is safe, ECMAScript can be dangerous

Remember: A regex that's fast on valid input might be catastrophically slow on invalid input.