RegExp.escape(str) is the built-in way to escape regex characters in JavaScript. It returns a string that matches str literally when you pass it to new RegExp(), wherever it lands in the pattern, including inside a character class and under the u and v flags. It ships in Node 24, Chrome 136, Firefox 134 and Safari 18.2.
Placement is the reason to switch. In our tests, lodash’s escapeRegExp and the escape-string-regexp package were correct when the escaped text was the whole pattern. They went wrong when it was one piece of a bigger one.
What does RegExp.escape actually output?
It looks odd the first time you print it:
RegExp.escape('foo.bar'); // '\\x66oo\\.bar'
RegExp.escape('1+1'); // '\\x31\\+1'
RegExp.escape('x-y'); // '\\x78\\x2dy'
RegExp.escape('a b'); // '\\x61\\x20b'
MDN’s reference page gives the rules. Syntax characters (^ $ \ . * + ? ( ) [ ] { } | and /) get a backslash. Other punctuators such as -, &, #, , and : become \x hex escapes, because a bare backslash in front of them is a syntax error under the u flag. Spaces and other whitespace are escaped too. And if the first character is an ASCII letter or digit, it becomes a hex escape as well.
The leading escape looks like noise. It exists because the escaped string might be glued straight after a backreference such as \1 or a partial hex escape such as \x0, and an unescaped digit or letter would extend that escape sequence instead of matching itself. Non-ASCII letters pass through untouched. Passing anything other than a string throws a TypeError.
The proposal finished at TC39 in February 2025 and is listed with a 2025 publication year in the TC39 finished proposals list. MDN marks it Baseline 2025.
Where do the old escape helpers break?
We ran the same inputs through native RegExp.escape, escape-string-regexp 5.0.0 and lodash 4.18.1’s escapeRegExp on Node 24.12.0, then built three kinds of pattern from each result: the escaped string alone, the string inside a character class, and the string after a backreference.
As a whole pattern, all three were correct for every input under no flags, u and v. If that is the only way your code uses its escape helper, there is no bug to fix, only a dependency to drop.
Inside a character class, lodash was the weakest. It does not escape -, so the user input a-z becomes the range [a-z]:
import _ from 'lodash';
const re = new RegExp('^[' + _.escapeRegExp('a-z') + ']+$', 'u');
re.test('m'); // true: a range, not three characters
re.test('a-z'); // false
new RegExp('[' + _.escapeRegExp('a--b') + ']', 'u');
// SyntaxError: Range out of order in character class
escape-string-regexp escapes - as \x2d, and its README says you can use it to escape a string inserted into a character class. That held under the u flag in our runs. Under the v flag it did not. v reserves doubled punctuators like && and -- for set operations and treats / as a syntax character inside a class, and the package escapes neither & nor /:
import escapeStringRegexp from 'escape-string-regexp';
new RegExp('^[' + escapeStringRegexp('a&&b') + ']+$', 'v').test('a&&b');
// false: [a&&b] is an intersection of a and b
new RegExp('[' + escapeStringRegexp('x/y') + ']', 'v');
// SyntaxError: Invalid character in character class
Native RegExp.escape matched its own input in every class test we ran (a-z, a--b, a&&b, plus x/y built without error), under both flags.
The backreference case caught both libraries:
const re = new RegExp('(a)\\1' + escapeStringRegexp('1'));
re.source; // '(a)\\11'
re.test('aa1'); // false
\11 is not “group 1 then a literal 1”. With only one capture group in the pattern and no u flag, it is read as the legacy octal escape for a tab, and in our run that pattern matched 'a\t' instead. Add the u flag and the same source throws Invalid escape. RegExp.escape('1') returns \x31, so the pattern matched aa1.
How do you use it in a search highlighter?
Highlighting a user’s search term is the textbook case for this, because the term is exactly the untrusted text that tends to contain +, ( or -:
// highlight.mjs: wrap every case-insensitive match of a user's search term in <mark>.
// Assumes text and term are already HTML-escaped.
export function highlight(text, term) {
if (!term) return text;
const pattern = new RegExp(RegExp.escape(term), 'giv');
return text.replace(pattern, (match) => `<mark>${match}</mark>`);
}
console.log(highlight('Size 1+1 bundle (2 pack) - 50% off', '1+1'));
console.log(highlight('Size 1+1 bundle (2 pack) - 50% off', '(2 pack)'));
console.log(highlight('a-b, a--b and a&&b', 'a--b'));
On Node 24.12.0 that prints:
Size <mark>1+1</mark> bundle (2 pack) - 50% off
Size 1+1 bundle <mark>(2 pack)</mark> - 50% off
a-b, <mark>a--b</mark> and a&&b
Note the function passed to replace(). RegExp.escape makes the pattern safe. It does nothing for the replacement string, where $&, $1 and $<name> are special:
const re = new RegExp(RegExp.escape('{{name}}'), 'g');
const userValue = 'Tom $& Jerry';
'Hi {{name}}'.replace(re, userValue); // 'Hi Tom {{name}} Jerry'
'Hi {{name}}'.replace(re, () => userValue); // 'Hi Tom $& Jerry'
If the replacement comes from a user or a CMS, pass it through a function. If all you need is a literal find and replace with no flags, replaceAll() with a string search avoids building a regex at all, though it still expands $& in a string replacement.
What about Node 22 and older browsers?
MDN’s compatibility data lists Node 24.0.0 as the first Node release with RegExp.escape, and the Node 24.0.0 release notes include it in the V8 13.6 feature list. Node 22 is not covered, and it is still getting LTS releases: 22.23.4 shipped on 20 October 2026, according to the Node.js release index. We did not run Node 22 for this post; that gap comes from the compat data. The same data lists Deno 2.3.2 and Bun 1.1.22.
If you have to support those runtimes, the es-shims polyfill (regexp.escape on npm, 2.0.1 when we tested) returns the native function when one exists and its own implementation otherwise:
// escape.mjs: native RegExp.escape where it exists, the es-shims polyfill elsewhere.
import getEscape from 'regexp.escape/polyfill';
export const escapeRegExp = getEscape();
We checked both paths on Node 24.12.0. With the built-in present, escapeRegExp was the native function. With RegExp.escape deleted before import, it fell back to the polyfill and still returned \x31\+1 for 1+1. We also compared the polyfill’s implementation with native output on 19 inputs, covering punctuators, whitespace, the U+2028 line separator, a lone surrogate and non-ASCII text, and found no differences. Nineteen inputs is a spot check, not proof of conformance.
For TypeScript, the method is typed in the es2025 lib. With TypeScript 7.0.2, a call fails under --lib es2024 with error TS2550 and passes under es2025 or esnext. If you are on the TypeScript 7 line already, our TypeScript 7 migration notes cover the rest of the tsconfig changes.
What changes when you migrate?
The output is different, so anything that asserts on .source will change. escapeStringRegexp('foo.bar') gives foo\.bar. RegExp.escape('foo.bar') gives \x66oo\.bar. Snapshot tests, logged patterns and cache keys built from a regex’s source all shift, even though the patterns match the same strings. Expect a noisy snapshot diff. Review it once; it is not a regression.
The same goes for regex source you build on the server and send to a client. Both ends will understand \x66, but anyone grepping logs for foo will no longer find it.
If you are trimming other small dependencies in favour of built-ins, our URLPattern routing notes cover a similar swap for path matching.
Should you switch?
Yes, wherever your floor is Node 24 or a Baseline 2025 browser set, and especially where escaped input is dropped into a character class, after a backreference, or into a v-flag pattern. Those are the cases where we saw lodash and escape-string-regexp produce wrong matches or throw. Use the es-shims polyfill if you still serve Node 22.
If your only use is new RegExp(escape(term)) with no flags or u, the old helper is not buggy for you. Switch when you next touch that code and do not schedule a migration for it.
We build Node.js services and storefront search, which is where this kind of input handling usually lives. If you want a second pair of eyes on yours, see our Node.js development work.