How to check if a string contains a substring in JavaScript
To check if a JavaScript string contains a substring, use includes(). Plus the indexOf zero trap, case-insensitive matching and searching with a regex.
To check if a JavaScript string contains a substring, use includes():
const message = "Hello, world";
message.includes("world"); // true
message.includes("World"); // false
includes() returns true if the substring appears anywhere in the string and false if it does not. It is case-sensitive, it takes an optional second argument for where to start looking, and it arrived in ES2015, so every browser still in use supports it.
That covers most cases. The rest of this post is about the ones it does not: the older indexOf idiom and the bug it invites, case-insensitive matching (which is harder than lowercasing), and the point where you genuinely need a regular expression. Every output below was measured in Node v24.14.1.
Check if a string contains a substring with includes()
The full signature is str.includes(searchString, position). The position is where the search starts:
const message = "Hello, world";
message.includes("world", 7); // true: "world" starts at index 7
message.includes("world", 8); // false: the search starts after it
message.includes("Hello", -5); // true: negative positions count as 0
Two behaviours surprise people.
The empty string is in every string
message.includes(""); // true
"".includes(""); // true
If the search term comes from a text box, an empty box matches everything. For a search filter that is usually what you want, since all results stay visible until the user types. For a "does this message contain a banned word" check it is a bug. Guard the empty case explicitly with term && text.includes(term).
includes converts its argument to a string
"null value".includes(null); // true
"is undefined".includes(undefined); // true
"v5".includes(5); // true
null becomes the text "null" and undefined becomes "undefined", so a variable that was never set quietly searches for the word "undefined". The one thing includes refuses outright is a regular expression:
message.includes(/world/);
// TypeError: First argument to String.prototype.includes must not be a regular expression
That error is deliberate, so nobody assumes includes does pattern matching. It does not. The regex section below covers what to use instead.
Arrays have an includes method too. It is a separate method that compares whole elements rather than looking for a substring. If you are checking a list rather than a piece of text, the post on checking if an array contains a value covers that one, including why [NaN].includes(NaN) is true.
indexOf() and the zero trap
Before includes existed, the standard approach was indexOf, which returns the position of the first match, or -1 when there is none:
message.indexOf("world"); // 7
message.indexOf("zzz"); // -1
message.indexOf("Hello"); // 0
You will still see it all over older code as indexOf(term) !== -1, and that is correct. The bug is the shortcut people take with it:
if (message.indexOf("Hello")) {
// never runs, even though "Hello" is there
}
if (message.indexOf("zzz")) {
// runs, even though "zzz" is not there
}
indexOf returns 0 when the match is at the very start of the string, and 0 is falsy. It returns -1 when there is no match, and -1 is truthy. So the shortcut is wrong in both directions. Measured, !!message.indexOf("Hello") is false and !!message.indexOf("zzz") is true.
This bug survives testing because most test strings have the match somewhere in the middle, where the index is a positive number and the shortcut happens to work.
Older code sometimes dodges it with ~message.indexOf(term), where the bitwise NOT turns -1 into 0. It works, and almost nobody can read it. Use includes when you want a yes or no, and indexOf only when you need the position itself. When you do, lastIndexOf searches from the end, and both accept a start index.
Case-insensitive contains
The standard answer lowercases both sides:
const containsIgnoreCase = (text, term) =>
text.toLowerCase().includes(term.toLowerCase());
containsIgnoreCase("Hello, world", "WORLD"); // true
Lowercase both, not just one. Lowercasing only the text and then searching for a user's "World" fails, and that version ships surprisingly often because the tests tend to use lowercase input.
A regular expression with the i flag does the same job:
/world/i.test("Hello, World"); // true
Where lowercasing is not enough
For English text, either approach is fine. Beyond English, lowercasing is a rougher tool than it looks:
"STRASSE".toLowerCase().includes("straße"); // false
"İstanbul".toLowerCase().includes("istanbul"); // false
"İstanbul".toLowerCase().length; // 9
German ß uppercases to "SS", but "SS" lowercases to "ss", never back to ß, so the two spellings of the same word never match. Turkish capital İ (with a dot) lowercases to a plain i followed by a separate combining dot, which is why an 8-letter word comes back 9 characters long and no longer contains "istanbul".
Accents have a related trap. An é can be stored as one code point, or as an e followed by a combining accent, and the two look identical on screen:
"café".includes("café"); // false
"café".normalize("NFC").includes("café".normalize("NFC")); // true
Text copied from file names on a Mac, from PDFs, and from some keyboards often uses the second form. It is the same combining accent that breaks the usual way to reverse a string. If users report that search "can't find a word that's right there", normalise both sides with normalize("NFC") before comparing.
Matching "cafe" against "café", or ignoring accents altogether, is beyond plain string methods. localeCompare and Intl.Collator can compare whole strings that way, but neither does substring search. That job usually belongs to a search library or to the database.
Use a regex when you need a pattern
includes only finds literal text. A regular expression lets you describe what counts as a match, and the most common reason to need one is matching whole words:
"category".includes("cat"); // true
/\bcat\b/.test("category"); // false
/\bcat\b/.test("a cat sat"); // true
\b is a word boundary, so \bcat\b matches "cat" as a word and not inside "category". Use test() for a yes or no answer; match() returns the matched text and search() returns its position.
Searching for user input with a regex
Building a regex from something a user typed breaks on punctuation:
new RegExp("c++");
// SyntaxError: Invalid regular expression: /c++/: Nothing to repeat
+, ., *, ?, brackets and parentheses all mean something in a regex. This catches people out with search() too, because search() quietly converts a string argument into a regex:
"file.txt".search("."); // 0
"file.txt".indexOf("."); // 4
The dot means "any character", so search(".") matched the f at index 0 rather than the dot at index 4.
The fix is escaping the input before it goes into the pattern. RegExp.escape() is new in ES2025 and works in Node 24 and current versions of Chrome, Firefox and Safari:
const term = "c++";
new RegExp(RegExp.escape(term), "i").test("I like C++"); // true
For older environments, the replacement MDN's regular expressions guide has long recommended does the same job:
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
The /g flag trap
const hasO = /o/g;
hasO.test("foo"); // true
hasO.test("foo"); // true
hasO.test("foo"); // false
A regex with the g flag remembers where its last match ended, in a property called lastIndex, and starts the next test() from there. Reusing one global regex across calls gives results that look random. For a contains check, leave the g off; it is only for finding every match.
Check for any or all of several substrings
To test a string against a list of terms, combine includes with the array methods some and every:
const text = "Order shipped to London";
const cities = ["Paris", "London", "Tokyo"];
cities.some((c) => text.includes(c)); // true: at least one
["Order", "London"].every((t) => text.includes(t)); // true: all of them
cities.find((c) => text.includes(c)); // "London": which one
some stops at the first match, so a long list costs nothing extra once a term is found. find answers the follow-up question of which term matched.
Watch the empty list. [].some(...) is false, which is what you would expect, but [].every(...) is true: there are no terms, so none of them are missing. If the list of required words comes from user settings, an empty list means every string passes.
For a long list checked against a lot of text, one regex built from all the terms does a single pass instead of one search per term. Escape each term first:
const anyCity = new RegExp(cities.map(RegExp.escape).join("|"), "i");
anyCity.test("shipped to london"); // true
The | means "or", and the i flag makes the whole check case-insensitive at once.
startsWith, endsWith and counting matches
When the substring has to sit at one end of the string, there are dedicated methods:
message.startsWith("Hello"); // true
message.endsWith("world"); // true
They say what you mean far more clearly than indexOf(term) === 0, and they avoid the zero trap entirely.
To count how many times a substring appears, rather than whether it appears, match with the g flag returns every match:
("banana".match(/an/g) || []).length; // 2
"banana".split("an").length - 1; // 2
match returns null rather than an empty array when nothing matches, hence the || []. The split version works because N separators cut a string into N + 1 pieces; split() and join() covers the edge cases of that method. Both count matches that do not overlap, so "aaaa" contains "aa" twice as far as either is concerned, not three times.
Which is fastest?
Here is a 1,000,000-character string with the match at the very end, each check repeated 200 times:
| Check | Time for 200 runs |
|---|---|
indexOf("needle") !== -1 |
6.3ms |
/needle/.test() |
6.3ms |
includes("needle") |
6.5ms |
/needle/i.test() |
19.8ms |
toLowerCase().includes("needle") |
77.4ms |
The first three are effectively the same speed. The useful result is the last row: the lowercase approach copies the entire string before it searches, on every call, so it was about four times slower than the i regex.
If you run a case-insensitive check against long text inside a loop, create the /term/i regex once and reuse it. For short strings the difference is microseconds, and the most readable version should win.
Quick reference
text.includes(term) // contains? (case-sensitive)
text.indexOf(term) !== -1 // older equivalent
text.toLowerCase().includes(term.toLowerCase()) // ignore case
/term/i.test(text) // ignore case, faster on long text
/\bterm\b/.test(text) // whole word only
new RegExp(RegExp.escape(term), "i").test(text) // user input inside a pattern
text.startsWith(term) // at the start
text.endsWith(term) // at the end
text.normalize("NFC") // before comparing pasted text
Want to practise string methods with code that runs as you type? Start the JavaScript track.
More from the blog
What if __name__ == '__main__' actually does in Python
In Python, if __name__ == '__main__' runs code only when you run the file directly, not when it is imported. What __name__ holds and when you need it.
Read moreHow to reverse a list in Python (and a string)
To reverse a list in Python, use reverse() to change it in place, [::-1] for a reversed copy, or reversed() to loop backwards. Which to use and why.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.