Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

JavaScript replace(): The First-Match Gotcha and What replaceAll Fixed

Sean

Platform Writer

Aug 08, 2026
7 min read

String.prototype.replace() replaces the first match only when you pass it a string. To replace every occurrence you need either a regular expression with the global flag or, since ES2021, replaceAll(). It also never modifies the original string — it returns a new one, which is the other half of why replace calls silently do nothing.

JavaScript replace(): The First-Match Gotcha and What replaceAll Fixed

Those two behaviours account for nearly every replace-related bug. The rest of the method is genuinely useful once you know what the replacement string’s dollar patterns do, and that a replacement function gives you far more control than most people use.

Table of contents

The first-match behaviour

This is the one that catches everyone:

'a-b-c'.replace('-', '+');        // 'a+b-c'  — only the first
'a-b-c'.replace(/-/g, '+');       // 'a+b+c'  — global flag
'a-b-c'.replaceAll('-', '+');     // 'a+b+c'  — clearer

A string pattern always replaces one occurrence. There is no option to change that; the global flag is a property of regular expressions, not of strings.

replaceAll with a string argument is the clearest expression of the common intent and should be the default in modern code. One rule to remember: if you pass replaceAll a regular expression, it must have the g flag or it throws a TypeError. That is deliberate — it catches the contradiction of asking for all matches with a non-global pattern.

The other half of the problem is immutability:

let s = 'hello';
s.replace('h', 'j');    // returns 'jello', s is unchanged
s = s.replace('h', 'j'); // now s is 'jello'

Strings are immutable in JavaScript. Every string method returns a new value, so a replace whose result is discarded does nothing at all — silently, with no error.

The dollar patterns

The replacement string is not literal. Several dollar sequences have meaning:

  • $& — the entire match.
  • $1, $2 — numbered capture groups.
  • $ — a named capture group.
  • $` — everything before the match.
  • $’ — everything after the match.
  • $$ — a literal dollar sign.

Capture groups are what make replace genuinely powerful:

'2026-08-08'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1');
// '08/08/2026'

// Named groups read much better
'2026-08-08'.replace(
  /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/,
  '$<d>/$<m>/$<y>'
);

The trap here is inserting user-controlled text as a replacement. If that text contains a dollar sign followed by a digit, it is interpreted as a capture group reference rather than literal text:

const userInput = 'Price: $1 off';
'PLACEHOLDER'.replace(/PLACEHOLDER/, userInput);
// The $1 becomes a group reference, not literal text

The fix is to use a replacement function instead, which treats its return value as literal with no dollar interpretation. That is the correct pattern any time the replacement is not a hardcoded string.

Replacement functions

Passing a function instead of a string gives you the match and lets you compute the replacement:

'a1b2c3'.replace(/\d/g, match => match * 2);
// 'a2b4c6'

The function receives the match, then each capture group, then the offset, then the whole string:

'John Smith'.replace(/(\w+) (\w+)/, (match, first, last) => {
  return `${last}, ${first}`;
});
// 'Smith, John'

Three reasons to prefer a function:

  1. Conditional replacement. Return the original match unchanged when you do not want to replace this particular occurrence.
  2. Computation. Anything beyond rearranging captured text.
  3. Safety. The return value is used literally, so dollar signs in dynamic content cause no surprises.

That third point makes replacement functions the right default for anything involving user input.

Escaping a dynamic pattern

A common need is building a regular expression from a variable, and it breaks the moment the variable contains a regex metacharacter:

const term = 'price (usd)';
new RegExp(term, 'g');   // SyntaxError — unmatched parenthesis

There is no built-in escape function, so you need one:

const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

const pattern = new RegExp(escapeRegExp(term), 'g');

Or avoid the problem entirely — replaceAll with a string argument does no regex interpretation at all:

text.replaceAll(term, 'replacement');   // term is literal

That is the strongest argument for replaceAll beyond clarity: passing a string means user input cannot become a pattern. Building regular expressions from unescaped user input is also a denial-of-service risk, since a crafted pattern can cause catastrophic backtracking.

Performance, briefly

For the vast majority of code this does not matter, and two things are worth knowing when it does.

First, replace and replaceAll on a large string in a loop creates a new string every iteration. Building a result with an array and joining once is meaningfully faster for heavy work, though modern engines optimise the common cases well enough that this is rarely the bottleneck.

Second, and more importantly: a poorly constructed regular expression can be catastrophically slow. Nested quantifiers like (a+)+ against a non-matching input cause exponential backtracking, which turns a millisecond operation into one that hangs the thread.

If a regular expression is applied to user-supplied input, it is worth checking for nested quantifiers and alternations that can match the same text in multiple ways. That is the pattern that produces a hang, and on a single-threaded runtime a hang is an outage.

Choosing quickly

A short guide:

  • Replacing a literal substring everywhere? replaceAll(string, string). Clearest, and safe with dynamic input.
  • Replacing one occurrence? replace(string, string).
  • Pattern matching required? A regex with the g flag.
  • Replacement depends on the match? A replacement function.
  • Replacement contains user input? A replacement function, always.

And the check worth doing on every replace call: is the result assigned to something? A discarded return value is the most common form of this bug and the one that produces no error at all.

How this fits the rest of the stack

Most of what goes wrong with replace is dynamic input meeting a syntax that interprets it — the same shape as a lot of production bugs, where a value that was always a literal in testing becomes user-controlled in the real world. Catching those means seeing the failing request and the code that produced it in one place rather than reconstructing from a stack trace. Node services on RunxBuild deploy from a repository with the runtime log attached to the deploy that caused it, and the RunxBuild hosting calculator itemises what the frontend, API, and database cost together.

Useful related references:

FAQ

Why does replace only replace the first match?

Because a string pattern always matches once. Use a regular expression with the g flag, or replaceAll, which replaces every occurrence and is clearer about the intent.

What is the difference between replace and replaceAll?

replace with a string replaces the first occurrence; replaceAll replaces all of them. With a regex both behave the same, except replaceAll throws a TypeError if the pattern lacks the g flag.

Why did my replace not change the string?

Strings are immutable, so replace returns a new string rather than modifying the original. The result must be assigned — a discarded return value produces no error and no effect.

What does $& mean in a replacement string?

The entire matched text. Other dollar patterns are $1 and $2 for numbered capture groups, $ for named groups, and $$ for a literal dollar sign.

How do I safely use user input as a replacement?

Use a replacement function. Its return value is treated literally, so a dollar sign followed by a digit in the input is not interpreted as a capture group reference.

#javascript replace#replaceAll#regex#string methods#javascript strings