JavaScript replace Only Changes the First Match: Use replaceAll
Contents
text.replace('cat', 'dog') changes only the first matching substring. Use text.replaceAll('cat', 'dog') to replace every literal occurrence, and keep the returned string. Neither call changes the original string.
If the replacement comes from a form or another external value, there is a second choice: text.replaceAll(search, () => replacement) preserves the replacement text literally. Passing that value directly can interpret dollar sequences such as $&. Fixing the number of matches does not fix replacement-template interpretation.
Choose match scope and replacement semantics separately
The replace reference documents the first-match behavior for string patterns. The replaceAll reference describes the all-occurrence alternative and requires g when its pattern is a regular expression.
For the input a.b aXb a.b, the fixture below verifies three different outcomes:
| Call | Result | What it diagnoses |
|---|---|---|
input.replace('a.b', 'X') | X aXb a.b | Literal search, first match only |
input.replace(/a.b/g, 'X') | X X X | Regex dot also matches the middle X |
input.replaceAll('a.b', 'X') | X aXb X | Every literal a.b is replaced |
Turning dynamic text into new RegExp(search, 'g') changes its meaning when it contains regex syntax. For a literal search, keep the search argument a string. When a regex is intentional, keep its pattern and flags explicit; replaceAll(/a.b/, 'X') throws rather than quietly replacing just once.
The literal-search choice does not disable replacement templates. In the fixture, passing '[$&]' directly produces [cat] [cat]; returning the same text from a callback produces [$&] [$&]. Likewise, a direct '$$' becomes one dollar per match, while the callback preserves both dollars. The replacement-function documentation explains that returned text does not undergo that template substitution.
Run a regression fixture before changing a helper
Save this as replace-check.mjs and run node replace-check.mjs. These assertions were executed during article preparation on October 28, 2026, with Node.js 24.13.0 on macOS. They require no packages and cover primitive strings and ordinary built-in regex objects.
import assert from 'node:assert/strict';
const input = 'a.b aXb a.b';const rows = [ ['replace string', input.replace('a.b', 'X'), 'X aXb a.b'], ['replace regex', input.replace(/a.b/g, 'X'), 'X X X'], ['replaceAll string', input.replaceAll('a.b', 'X'), 'X aXb X'], ['replacement template', 'cat cat'.replaceAll('cat', '[$&]'), '[cat] [cat]'], ['literal callback', 'cat cat'.replaceAll('cat', () => '[$&]'), '[$&] [$&]'], ['dollar template', 'cat cat'.replaceAll('cat', '$$'), '$ $'], ['dollar callback', 'cat cat'.replaceAll('cat', () => '$$'), '$$ $$'], ['non-overlapping', 'aaa'.replaceAll('aa', 'X'), 'Xa'], ['no recursive pass', 'a'.replaceAll('a', 'aa'), 'aa'], ['empty search', 'ab'.replaceAll('', '-'), '-a-b-'],];for (const [label, actual, expected] of rows) { assert.equal(actual, expected); console.log(`${label}: ${JSON.stringify(actual)}`);}assert.equal(input, 'a.b aXb a.b');assert.throws(() => input.replaceAll(/a.b/, 'X'), TypeError);console.log('non-global regex: TypeError; original unchanged');
function replaceEveryLiteral(text, search, replacement) { if (![text, search, replacement].every(value => typeof value === 'string')) { throw new TypeError('Expected three strings'); } if (search.length === 0) throw new RangeError('Search must not be empty'); return text.replaceAll(search, () => replacement);}assert.equal(replaceEveryLiteral(input, 'a.b', '$&'), '$& aXb $&');assert.equal(replaceEveryLiteral('cat cat', 'cat', ''), ' ');assert.equal(replaceEveryLiteral('cat', 'dog', '$$'), 'cat');assert.throws(() => replaceEveryLiteral('ab', '', '-'), RangeError);assert.throws(() => replaceEveryLiteral('ab', /a/g, '-'), TypeError);assert.throws(() => replaceEveryLiteral(null, 'a', '-'), TypeError);console.log('literal helper: metacharacters and dollars preserved; invalid inputs rejected');Expected output:
replace string: "X aXb a.b"replace regex: "X X X"replaceAll string: "X aXb X"replacement template: "[cat] [cat]"literal callback: "[$&] [$&]"dollar template: "$ $"dollar callback: "$$ $$"non-overlapping: "Xa"no recursive pass: "aa"empty search: "-a-b-"non-global regex: TypeError; original unchangedliteral helper: metacharacters and dollars preserved; invalid inputs rejectedThe first three rows isolate search behavior using one unchanged input. The next four isolate replacement behavior using the same two matches. Keeping those checks separate makes a regression easier to locate: an unexpected aXb replacement points to regex interpretation, while lost dollar characters point to the replacement argument.
Give literal replacement a narrow input contract
The replaceEveryLiteral() helper in the fixture accepts exactly three primitive strings and rejects an empty search. Its callback closes over replacement; it does not use or transform the matched text. The assertions verify literal metacharacters, literal dollar sequences, deletion with an empty replacement, and an absent match. Invalid types fail before replacement begins.
| Required behavior | Choice verified by the fixture |
|---|---|
| Replace every literal search occurrence with fixed ordinary text | String pattern with replaceAll() |
| Preserve replacement text containing dollar sequences | Return the text from a callback |
Intentionally expand $& into the matched text | Pass a replacement string directly |
| Delete matching text | Use an empty replacement string |
| Reject an accidentally blank search field | Validate the search before calling replaceAll() |
Rejecting empty search is an application policy, not a requirement of the native method. The fixture shows native behavior on ab: an empty search inserts - before, between, and after its characters, producing -a-b-. A replacement form that unexpectedly receives a blank field would otherwise change more positions than its user intended.
Do not validate replacement text with a truthiness check if an empty replacement means deletion. That is the same distinction between missing values and meaningful falsy values covered in the non-nullish filtering guide.
Verify boundaries beyond the first-match fix
The last three native-method rows check boundaries defined by the ECMAScript replaceAll algorithm. Matches do not overlap: replacing aa in aaa produces Xa, leaving the final character. Replacement text is not searched again in the same call: replacing a with aa finishes at aa.
Those assertions matter when replacing a loop that repeatedly calls replace(). A loop that keeps searching the newly returned string has a different contract and can keep expanding its own replacement. Define whether the operation is one pass over the input before choosing an implementation.
This fixture does not test browser engines, older runtimes without replaceAll(), custom Symbol.replace methods, Unicode normalization, or performance on large strings. It is a text transformation helper, not an HTML sanitizer or a word-boundary redaction tool. If matching must ignore case, normalize Unicode, or respect words, define and test that additional matching rule separately.
Before adopting the fix, retain a test with repeated matches, a regex metacharacter in the search, and a dollar sequence in the replacement. Assert the returned string as well as the unchanged input so a correct replacement cannot be discarded by its caller.
References: