Quick answer
Pass a function as the second argument to replace() when the replacement depends on the matched content. The callback receives the full match, then each capture group, then the offset, then the input string, then a groups object if you used named groups. Return a string; it replaces the match.
String replacement is one of the most common reasons developers reach for regex. Using a function instead of a static string unlocks transformations that would otherwise need two passes. Here is the full signature, the gotchas, and when to prefer a callback over a string.
// Convert all USD prices to CHF, computed per-match
const usdToChf = 0.89;
const text = 'Total: 99 USD before tax. Shipping 15 USD extra.';
const out = text.replace(/(\d+)\s*USD/g, (full, amount) => {
const chf = (Number(amount) * usdToChf).toFixed(2);
return chf + ' CHF';
});
console.log(out);
// 'Total: 88.11 CHF before tax. Shipping 13.35 CHF extra.'
// With named groups, use the last argument:
text.replace(/(?<n>\d+)\s*USD/g, (_full, _n, _off, _src, groups) => {
return (Number(groups.n) * usdToChf).toFixed(2) + ' CHF';
});
The full callback signature
When you pass a function to replace(), JavaScript invokes it for every match (with /g) or just once (without). The arguments, in order:
match: the full matched substringp1, p2, ...: each capture group (one argument per group)offset: the zero-based index of the match in the inputstring: the entire input stringgroups: an object with named groups (if any)
The return value is converted to string and substituted for the match. undefined becomes the literal string undefined, which is rarely what you want, so always return an explicit string.
When to use a function over a string
Static replacement strings cover most cases: '$1-$2', '$<name>', plain text. Reach for a function when:
- The replacement depends on the matched value. Currency conversion, unit translation, percentage scaling.
- You need conditional logic. "Replace if the match is in this list, leave alone otherwise."
- You want to maintain external state. Counting matches, building an index, accumulating side effects.
- The replacement involves async work that you can pre-compute. Fetch all needed data first, then run a sync replace.
If your function is a one-liner that just rearranges captures, the string form is faster and more readable. If you find yourself reaching for a switch, a function is the right tool.
Async traps
The callback is called synchronously. Returning a Promise gives you the literal string "[object Promise]" replacing every match. There is no built-in replaceAsync(), although the pattern is straightforward:
- First pass: collect every match (use
matchAll()). - Resolve all the async work in parallel (
Promise.all). - Second pass: a synchronous
replace()with a function that pulls from the resolved map.
The two-pass form is always preferable to fighting the synchronous callback. It also gives you parallelism, which a sequential async loop would not.
Performance, plain strings vs functions
On V8, with a typical pattern and a million matches:
- String replacement: roughly 2 to 5 nanoseconds per match (the engine does the substitution natively).
- Function replacement: roughly 30 to 100 nanoseconds per match (function call overhead plus argument allocation).
For under 100,000 matches per call, the difference is irrelevant. For tight inner loops on a million matches per second, the function form has a real cost. The fix, if you really need it, is to compile the transformation table once and use the function only when the lookup misses.
Named groups in the callback
If your regex uses named groups, the groups object is passed as the last argument:
str.replace(re, (full, ...args) => { const groups = args[args.length - 1]; ... })- or destructure with placeholders for the positional groups.
Some teams adopt the convention of always destructuring the last argument when the regex has named groups, since it makes the intent obvious to readers. Once you mix positional and named, the function signature can grow long; named groups plus the trailing groups object is the cleanest pattern.
Common pitfalls
Three to watch for:
- Forgetting
/g. Without the global flag, the function runs once. Easy to miss when you copy a regex from somewhere. - Mutating outside state during the replacement. It works, but it makes the function order-dependent. If you only need a count, do a
matchAll()first and replace separately. - Returning non-string values. The engine coerces to string. Numbers become digits, objects become "[object Object]". If your callback might return a non-string, wrap it in
String().
Used carefully, the callback form of replace() is one of the most expressive tools in JavaScript's regex API. It is also one of the most overlooked.