replace() as an iterator
Contents
String.prototype.replace() with a global regular expression and a function has iterated over the interesting parts of a string since 1999, long before array extras like forEach() and filter(). It is rarely the best tool today, but it is still in a lot of code, for historical reasons and for a certain technical beauty. Let’s see how it works, what it is good for, and what it costs, so that a replace() whose result goes unused makes sense when we meet it.

The trick
When replace() gets a regular expression with the g flag and a function, it calls the function once per match. The function receives the match, the captures, the offset, and the whole string, and whatever it returns is spliced into a new string. We do not need that string.
const out = [];
'a1b22c333'.replace(/\d+/g, m => (out.push(m), ''));
// out: ['1', '22', '333']
Return the empty string. A callback that returns undefined has the text "undefined" spliced in at every match, so the result we are about to throw away grows with each one. The empty string keeps it to the unmatched remainder, 'abc' here, which is as small as this form gets. It is good hygiene: less memory pressure, important for memory-intensive programs, and fewer chances to run out of memory. I always return ''.
Filter and map, in one callback
Branch inside the callback and it is a filter; use the captures and it is a map. The shape is array.filter().forEach() with no array in our code.
let sum = 0;
'a1b22c333'.replace(/\d+/g, m => {
if (m.length > 1) sum += +m;
return '';
});
// sum: 355
const params = {};
'a=1&bb=22&ccc=333'.replace(
/(\w+)=(\d+)/g,
(m, k, v) => (params[k] = +v, '')
);
// params: {a: 1, bb: 22, ccc: 333}
The regex picks the parts, the captures name them, and the callback does the work. The parts the regex skips never reach the callback, so the first round of filtering happens in the engine’s matcher instead of in our loop. For the same sum, match() would hold every match in an array, while the callback keeps one number. On V8 the call itself still holds every match while it runs. John Resig used the same shape on a query string in “Search and Don’t Replace”.
The parser nobody needed
On several occasions I have seen code that splits, trims, filters, walks indices, or grows a small parser to do what one regex and a callback do. The trivial case first. Fields separated by commas, padded with spaces, some of them empty:
' 1,, 2 , 3 '
.split(',')
.map(s => s.trim())
.filter(s => s);
// ['1', '2', '3']
Three stages, two intermediate arrays, one result. In its favor, it is easy to read: each stage does one obvious thing, and a reader who has seen split() and trim() needs no explanation. That is a definite plus. The same with one regex:
const fields = [];
' 1,, 2 , 3 '.replace(
/[^,\s](?:[^,]*[^,\s])?/g,
m => (fields.push(m), '')
);
// fields: ['1', '2', '3']
The pattern says what a field is. It starts and ends on a character that is neither a comma nor whitespace and keeps anything but commas in between, so a value with spaces inside survives intact while the padding around it stays outside the match. An empty field has no character to start on, so it does not match. When collecting is all the callback does, match() with the same regex returns that array directly, or null when nothing matches. It holds every match at once, which makes iterating later easy; that is what arrays are for.
The elaborate versions are a different matter. They walk the string by index, test characters one at a time, keep state in a handful of variables, and grow a case for every new wrinkle. They are small parsers. Each one I came across was harder to follow than the pattern and callback that could replace it. The readability argument that favors the chain over the regex turns around here: for what a field or a token looks like, the pattern is the readable version.
A lexer in one regex
I have seen code that packed the lexer of a small language into one regular expression and drove the compiling or interpreting straight from replace(). If I remember correctly, I once wrote a calculator that way: C-style infix expressions in, the result out. A sketch in that spirit:
const precedence = {'+': 1, '-': 1, '*': 2, '/': 2, '%': 2};
const operators = {
'+': (a, b) => a + b,
'-': (a, b) => a - b,
'*': (a, b) => a * b,
'/': (a, b) => a / b,
'%': (a, b) => a % b
};
class Calc {
static lexer = /(?<num>\d+(?:\.\d+)?)|(?<op>[-+*\/%()])|\s+/g;
values = [];
pending = [];
#reduce() {
const {values, pending} = this;
const op = operators[pending.pop()];
if (!op || values.length < 2) {
throw new SyntaxError('Malformed expression');
}
const b = values.pop(), a = values.pop();
values.push(op(a, b));
}
#consume = (...args) => {
const {num, op} = args.at(-1), {values, pending} = this;
if (num) {
values.push(+num);
} else if (op === '(') {
pending.push(op);
} else if (op === ')') {
while (pending.at(-1) !== '(') this.#reduce();
pending.pop();
} else if (op) {
const p = precedence[op];
while (precedence[pending.at(-1)] >= p) this.#reduce();
pending.push(op);
}
return '';
};
parse(s) {
const rest = s.replace(Calc.lexer, this.#consume);
if (rest) throw new SyntaxError('Unexpected ' + rest);
return this;
}
end() {
const {values, pending} = this;
while (pending.length) this.#reduce();
if (values.length !== 1) {
throw new SyntaxError('Malformed expression');
}
return values.pop();
}
}
const calc = expr => new Calc().parse(expr).end();
calc('2 + 3 * (4 - 1) % 5'); // 6
const c = new Calc();
for (const line of ['2 + 3', '* (4 - 1)', '% 5']) c.parse(line);
c.end(); // 6
The static regex is the whole lexer: a number, an operator or a parenthesis, or a run of whitespace. #consume is the parser: the shunting-yard algorithm on two stacks, applied to each token in turn. replace() calls its callback without a this, so #consume is an arrow function that uses the object’s this directly.
The lexer also checks the characters. It consumes every valid character, and the callback returns '' for each token, so a valid expression leaves the empty string. Whatever is left is the characters the lexer did not recognize: '(1 + 2) * a' leaves 'a'. A non-empty result means a problem and names the offending characters, though not where they were. The string we usually throw away is the error report.
The groups tell the parser which token it got. They can be positional, (m, num, op) => …, or named, as here. When a regex has named groups, the callback receives an object with all of them as its last argument, so args.at(-1) finds it however many groups come before. Either way, replace() does not tell us which alternative matched. They are mutually exclusive, yet the callback tests them one by one. For a handful of token kinds, a linear check is fine.
The parser is an object because its state outlives one call. parse() takes the input in pieces, such as lines of a file, and end() finishes the expression when the input runs out. The pieces have to break between tokens: '1' followed by '2 + 3' reads as two numbers in a row, and end() rejects it.
Shunting-yard is the usual way to implement an operator-precedence parser, a bottom-up kind, and that kind fits replace(): it takes tokens as they come and keeps its state in explicit stacks. Top-down parsers are easier to write, but they pull the next token. Under replace() they need the tokens in an array first, or a generator the callback can resume. My parser-toolkit has a fuller bottom-up parser, the classic stream-based operator-precedence parser by Max Motovilov. A series on small compilers, parsers like this one included, is on the way.
The alternatives
Four other built-ins work with a global regular expression.
exec() in a loop. The classic. The regex carries lastIndex between calls, so the loop is stateful, the regex must be global or sticky, and it must not be reused elsewhere without resetting lastIndex.
const re = /\d+/g;
const out = [];
let m;
while ((m = re.exec(s)) !== null) {
out.push(m[0]);
}
match() with a global regex. Returns the matches as an array and drops the captures. Fine when the match is all we need.
split(). The complement: it hands us the parts between matches. Good for delimiters, wrong for extraction, and the source of the three-stage chain above.
matchAll(). An iterator object, ES2020: lazy, keeps the captures, and with the iterator helpers it reads like the array chain it replaces.
const nums = s.matchAll(/\d+/g).map(m => m[0]).toArray();
// ['1', '22', '333']
matchAll() is where new code goes. But it is a recent addition, and the helpers are more recent still, while replace() with a function was in ECMAScript 3 in 1999. For two decades it was the one built-in that called our code for every match, and it runs in every engine and codebase we will meet. replaceAll() (ES2021) accepts the same callback and adds no capability here; it throws on a non-global regex where replace() matches once.
The numbers
Our nano-bench at its defaults: 100 samples of 50 ms per function, one process per file, runtime, and input size, run one after another on each machine. Its Concepts page covers how the intervals and the significance tests are computed. Two machines: an i9-11900K desktop and an i3-10110U laptop. Five runtimes: Node 22.23.3 (V8 12.4), Node 24.21.0 (V8 13.6), Node 26.10.0 (V8 14.6), Deno 2.9.7 (V8 15.0), and Bun 1.4.3, which runs JavaScriptCore.
The inputs are strings shaped like 'a1b22c333' at 1 KB, 100 KB, and 10 MB, generated from a fixed seed, so every run sees the same strings. Five contenders walk the matches of /\d+/g and sum their lengths: replace() returning '', the exec() loop, match(), matchAll() with for-of, and matchAll() with the helpers’ forEach(). The bench files are published as a runnable gist, so npm install && npm run bench repeats the experiment on other hardware.
replace() against the exec() loop, as the ratio of their medians (2.00 means twice as slow):
| runtime | i9, 1 KB | i9, 100 KB | i9, 10 MB | i3, 1 KB | i3, 100 KB | i3, 10 MB |
|---|---|---|---|---|---|---|
| Node 22.23.3 | 1.65 | 2.31 | 3.58 | 1.51 | 1.90 | 2.98 |
| Node 24.21.0 | 1.55 | 2.00 | 3.72 | 1.54 | 1.87 | 3.13 |
| Node 26.10.0 | 1.54 | 1.68 | 3.18 | 1.47 | 1.54 | 2.91 |
| Deno 2.9.7 | 1.50 | 1.61 | 3.24 | 1.46 | 1.52 | 2.85 |
| Bun 1.4.3 | 1.68 | 1.38 | 1.61 | 1.40 | 1.23 | 1.30 |
For scale, on Node 26 on the i9: replace() took 9.6 µs on 1 KB, 1.1 ms on 100 KB, and 215 ms on 10 MB; the exec() loop took 6.2 µs, 0.65 ms, and 67 ms.
On V8, replace() is the slowest of the five contenders in every run, and the gap grows with the input: 1.5 times the exec() loop at 1 KB, 2.9 to 3.7 times at 10 MB. V8’s source points at a reason. With a global regex and a function, it collects every match into one array before calling our function for the first one, and builds the result string after the last, which is the algorithm the specification describes. On Bun the ratio stays between 1.2 and 1.7, and the slowest contender there is matchAll() with forEach(), at 2.1 to 2.7 times the loop. matchAll() with for-of runs 7% to 22% behind the exec() loop everywhere. The fastest was usually match(), which returns plain strings without match objects.
A second file measured the return value. Returning undefined costs up to 9% more than returning '', and returning the match costs 6% to 13% more on V8 and 18% to 34% more on Bun. Time does not follow the length of the discarded string: on 10 MB, the 20 MB result of undefined beat the 10 MB result of returning the match. A benchmark runs alone, with nothing else allocating beside it. In a real program the discarded string adds to everything else in memory, so the reason to return '' is memory, and the time it saves is a bonus.
My node-re2 binds Google’s RE2, which matches in linear time, so no input can trigger catastrophic backtracking. With it the order turns around. It is a native addon, so a third file ran the same five contenders on the three Node versions only. There replace() took 0.28 to 0.38 of the exec() loop’s time on both machines, and match() was faster still, at 0.15 to 0.24. Both forms of matchAll() ran 2% to 12% behind the loop. I wrote as much of node-re2 as I could in C++, inlined what I could, went easy on code reuse, and optimized replace() with a function by hand. match() and replace() walk the whole string in C++, while each exec() call is a separate trip into the addon, and matchAll() is a JavaScript generator over exec(). All of them are slower than the built-in engine. On Node 26 on the i9 at 10 MB, replace() took 513 ms against 215 ms, match() 299 ms against 84 ms, and the exec() loop 1.56 s against 67 ms.
Every difference reported here passed the significance test, except returning undefined against '' on Node 22 at 10 MB on the i9, 1.4% apart. At 10 MB most contenders warned that their samples split into two groups, which usually means some samples paid for a garbage collection.
When to use it
Where we meet it in existing code, it works, and it rarely needs a rewrite. Two cases do call for one: large inputs on V8, and code that has to stop early or await between matches, which a callback cannot do short of throwing. matchAll() with for-of handles both. In new code, matchAll() comes first, and match() when the matches are all we want. replace() keeps a few things of its own: the leftover string as an error report, a lexer in a single call, and every engine since ES3. Under node-re2, slower than the built-in engine either way, it beats every way through the matches except match().
Summary
replace(/re/g, fn)calls the function once per match, which makes it an iterator over the parts a regex selects.- Return
''from the callback: the result string is built either way, and the empty string keeps it as small as it gets. - Branch in the callback for a filter, use the captures for a map; the pieces the regex skips are not seen.
- A pattern that says what a field is can replace the split-trim-filter chain and the hand-written scanner. A lexer in one regex can feed a bottom-up parser, and whatever it leaves over is the error report.
- On V8 it is the slowest of the five ways to visit matches we measured: 1.5 times the
exec()loop on 1 KB, 2.9 to 3.7 times on 10 MB. With node-re2, slower than the built-in engine across the board, it is about three times as fast as its loop, second only tomatch(). matchAll()is the modern form and where new code should go.replace()was there for twenty years before it and is still everywhere: areplace()whose result goes unused is usually an iterator.