indexOf()

ES3+

Returns the index of the first occurrence of a search value within a string, or -1 if it is not found, optionally starting the search at a given position. Indices count UTF-16 code units from zero, and the search is case-sensitive and literal. It is the classic workhorse for locating where in a string something sits.

Syntax

string.indexOf(searchValue, fromIndex)

Parameters

searchValue string

The value to search for

fromIndex number optional

Position to start searching from

Return Value

number

The index of the first occurrence, or -1 if not found

Examples

JavaScript
const str = 'Hello World';
console.log(str.indexOf('o'));
console.log(str.indexOf('x'));
Output:
// 4 -1

📌 When to Use

Use indexOf() when you need the position of a substring, not merely whether it exists: finding the @ in an email so you can slice out the user and domain, locating a delimiter to split around manually, finding where a query string begins, or walking through every occurrence of a token by feeding each hit plus one back in as the fromIndex. Position is the differentiator: for plain yes-or-no containment, includes() says it better, and for anchored checks startsWith() and endsWith() say it better still, so modern code reserves indexOf() for when the number itself will be used, almost always alongside slice() or substring(). The fromIndex parameter turns it into a cursor: the find-all loop of repeatedly calling indexOf(search, lastHit + 1) until -1 is the standard allocation-free way to enumerate occurrences without a global regex. Its mirror, lastIndexOf(), searches from the end and is the usual tool for file extensions. Remember the contract: -1 means not found, so the test is idx !== -1, never a truthiness check, because index 0 is a perfectly good hit and is falsy. The search is case-sensitive and counts UTF-16 code units, so positions can straddle surrogate pairs in emoji-bearing text; slice on indices you obtained from indexOf() of the same string and you stay consistent.

⚠️ Common Mistakes

Treating the return value as a boolean: if (str.indexOf(x)) skips matches at position 0 and treats 'not found' (-1) as true. The correct test is !== -1, or better, use includes() when only existence matters. This bug has shipped in production code for decades.

Forgetting -1 handling before slicing: email.slice(0, email.indexOf('@')) on a string without an @ slices with -1 and silently drops the last character instead of failing. Check for -1 first, then slice.

Expecting case-insensitive matching: indexOf() is strictly case-sensitive, so searches driven by user input usually need both haystack and needle lowercased first, at which point the returned index refers to the lowercased string, which matters if you then slice the original.

Off-by-one loops when finding all occurrences: continuing the search from index rather than index + 1 loops forever on the same hit, and continuing from index + search.length skips overlapping matches, which may or may not be intended. Choose the increment consciously.

Assuming indices count characters: they count UTF-16 code units, so text containing emoji or other astral symbols has indices that do not line up with what a user perceives as character positions, and slicing at an arbitrary computed offset can split a surrogate pair.

Searching for the empty string and being surprised: indexOf('') returns the fromIndex (clamped to the length), not -1, so empty search input looks like an instant match at position 0 unless you screen it out.

✅ Best Practices

Let the method match the question: includes() for existence, startsWith()/endsWith() for anchored checks, and indexOf() only when the position will actually be used in later slicing or logic.

Always compare against -1 explicitly and handle that branch first, so the not-found path is visible and the found path can use the index without caveats.

Name the index and reuse it, as in const at = email.indexOf('@'), rather than calling indexOf() twice for the two sides of a split; it is both clearer and cheaper.

Use the fromIndex-cursor loop for find-all scans over large text when you want positions without the allocation of match() results, and pick the step size deliberately for overlapping versus disjoint matches.

Reach for lastIndexOf() when the meaningful occurrence is the final one, as with file extensions and path separators, instead of looping forward to the end.

When both position and pattern-power are needed, use regex exec() or matchAll(), which return indices and capture groups together; indexOf() is for literal text only.

⚡ Performance Notes

indexOf() is a tight, allocation-free substring scan, linear in the searched portion, and engines implement it with optimized memory comparison, so it is among the fastest ways to locate literal text, matching includes() (which is the same search returning a boolean) and beating any equivalent regex once compilation and match-object overhead count. The cursor pattern with fromIndex enumerates every occurrence of a token in a large document with zero garbage, which is why parsers and log scanners favor it over match() with a global flag, whose result arrays allocate per call. Searching from a fromIndex also lets you skip known prefixes instead of rescanning them. Costs to watch are around indexOf(), not in it: lowercasing large haystacks for case-insensitive search allocates copies, and repeated indexOf() calls for the same needle in the same string should be hoisted into a saved index.

🌍 Real World Example

String Parsing and Extraction

Three position-driven parsers. parseEmail() finds the @ once, guards the -1 case by returning null, then slices both sides of the saved index to produce username and domain, the canonical locate-check-slice sequence. findAllOccurrences() is the classic cursor loop: each hit is pushed and the search resumes at the following position until -1 arrives, collecting every position of 'a' in 'banana' without regex or allocations beyond the result array. getQueryParam() chains several searches, first for the question mark, then for the parameter name and the following ampersand, slicing between the found offsets to extract one value from a URL by hand.

// Parse email into username and domain
function parseEmail(email) {
  const atIndex = email.indexOf('@');
  if (atIndex === -1) {
    return null;
  }
  return {
    username: email.slice(0, atIndex),
    domain: email.slice(atIndex + 1)
  };
}

console.log(parseEmail('user@example.com'));
// { username: 'user', domain: 'example.com' }

// Find all occurrences of a substring
function findAllOccurrences(str, search) {
  const positions = [];
  let index = str.indexOf(search);

  while (index !== -1) {
    positions.push(index);
    index = str.indexOf(search, index + 1);
  }

  return positions;
}

console.log(findAllOccurrences('banana', 'a')); // [1, 3, 5]

// Extract query string parameters
function getQueryParam(url, param) {
  const queryStart = url.indexOf('?');
  if (queryStart === -1) return null;

  const query = url.slice(queryStart + 1);
  const paramStart = query.indexOf(param + '=');
  if (paramStart === -1) return null;

  const valueStart = paramStart + param.length + 1;
  const valueEnd = query.indexOf('&', valueStart);
  return valueEnd === -1
    ? query.slice(valueStart)
    : query.slice(valueStart, valueEnd);
}

console.log(getQueryParam('https://example.com?name=John&age=30', 'name'));
// 'John'

Related Methods