I want to tell you about a Friday afternoon I’d rather forget.

We had a feature that parsed user-submitted templates — think mail merge but for Slack messages. Someone on the team wrote a regex to extract the {{variable}} placeholders. It worked in every test. Worked in staging. Shipped on a Thursday.

By Friday afternoon, two customers had reported that their templates were silently eating chunks of text. Not erroring out. Just… gone.

That bug, and the two others I’ve hit since, all come down to the same category of mistake: regex behaves differently than you expect in ways that are completely logical once you understand them, and completely invisible until they aren’t.

Here they are.


1. The Greedy Quantifier That Ate Too Much

The original template regex looked roughly like this:

const re = /\{\{(.+)\}\}/g;

Looks fine. But try it on a string with two variables:

Hello {{firstName}}, your order {{orderId}} is ready.

Run it:

const match = re.exec('Hello {{firstName}}, your order {{orderId}} is ready.');
console.log(match[1]); // "firstName}}, your order {{orderId"

.+ is greedy. It matches as much as possible before backing off. So (.+) eats everything from firstName all the way to orderId — because }} appears twice and .+ goes for the last one.

The fix is .+? — lazy quantifier. Match as little as possible before stopping:

const re = /\{\{(.+?)\}\}/g;

Now it stops at the first }} it sees. This is the single most common regex mistake I’ve seen in codebases. Everyone writes .+ when they mean .+?.

I’ve started auditing any regex that matches between two delimiters and asking: should this be lazy? Almost always yes.


2. The g Flag That Breaks test()

This one is sneakier and it took me embarrassingly long to figure out.

We had a validator that checked if a string contained at least one digit:

const hasDigit = /\d/g;

function validate(input) {
  return hasDigit.test(input);
}

Works fine in isolation. But someone called it in a loop:

const inputs = ['hello', 'world', 'pass1', 'word2'];
inputs.forEach(input => {
  console.log(validate(input));
});
// false, false, true, false  ← "word2" should be true!

What’s happening: when you use a regex with the g flag, the regex object is stateful. After a successful match, lastIndex advances to the position after the match. The next call to .test() starts from there, not from the beginning.

So the third call (pass1) matches at index 4, sets lastIndex = 5. The fourth call (word2) starts searching from position 5 — past where the 2 is — and finds nothing.

Two fixes:

// Fix 1: don't use g flag for .test()
const hasDigit = /\d/;

// Fix 2: reset lastIndex manually
const hasDigit = /\d/g;
hasDigit.lastIndex = 0; // reset before each call

I now have a rule: never put the g flag on a regex that’s stored in a variable and reused across calls with .test() or .exec(). Either define it inline (new object each time) or drop the g flag.

The g flag is for str.match(), str.matchAll(), and str.replaceAll(). Using it with .test() or .exec() in a loop is almost always a bug.


3. The . That Doesn’t Match Newlines

This one hit us in a content sanitizer. We were stripping out a block comment pattern from user-submitted code:

const code = input.replace(/\/\*.*\*\//g, '');

Works on single-line comments. Falls apart on:

/* This is a
   multi-line comment */

Because . doesn’t match \n by default. The regex can’t cross the line boundary, so the comment survives.

The fix in modern JavaScript is the s flag (dotAll), added in ES2018:

input.replace(/\/\*.*?\*\//gs, '');

The s makes . match any character including newlines. Combined with ? to make it lazy (see bug #1), this now correctly strips multi-line block comments.

If you’re stuck on an older environment without s support, the workaround is [\s\S] instead of .:

input.replace(/\/\*[\s\S]*?\*\//g, '');

[\s\S] means “any whitespace or non-whitespace character” — which is everything, including newlines.


The Pattern I Follow Now

When I write a regex now I ask four questions before shipping it:

  1. Do I have .+ or .* matching between delimiters? → Should probably be lazy (.+?, .*?)
  2. Am I reusing a regex object with g flag across multiple .test() calls? → Drop the g or reset lastIndex
  3. Could the input span multiple lines? → Add the s flag or use [\s\S]
  4. Have I tested with two instances of the pattern in one string? → The most common edge case that breaks greedy patterns

These four questions would have caught all three of the bugs above before they shipped.

If you want to test your regex against actual input before it goes anywhere near production, the Genbox regex tester runs entirely in your browser — paste your pattern, test string, and tweak flags in real time.


The template parser bug, by the way, was fixed with a one-character change: .+.+?. One character, two hours of debugging, two unhappy customers. I’ve tested every regex with multiple instances of the target pattern ever since.