map() vs forEach(): When to Use Which

A comprehensive comparison of two popular array methods and when to use each one.

The Core Difference

map() builds and returns a new array out of whatever your callback returns. forEach() always returns undefined and exists purely to run a side effect once per element. Almost everything else — the callback signature, the optional thisArg, the way sparse slots are skipped — is identical, which is exactly why the two are so often confused.

// map() - collects the callback's return values
const doubled = [1, 2, 3].map(x => x * 2);
console.log(doubled); // [2, 4, 6]

// forEach() - discards the callback's return values
const nothing = [1, 2, 3].forEach(x => x * 2);
console.log(nothing); // undefined

Both callbacks receive three arguments: the current element, its index, and the array being traversed. Keep that third and second argument in mind — they cause a famous bug we will look at below.

What the Specification Actually Guarantees

The ECMAScript spec defines both methods with almost the same steps, and the details matter in edge cases:

  • The length is read once, before iteration starts. Elements you append inside the callback are never visited. Elements you delete before their turn are skipped.
  • Holes are skipped in both methods. The callback is not invoked for empty slots of a sparse array. map() preserves the holes in the result rather than filling them with undefined.
  • map() creates its result with the species constructor. If you subclass Array, map() returns an instance of your subclass, while forEach() has no result at all.
const sparse = [1, , 3]; // hole at index 1
const mapped = sparse.map(x => x * 2);
console.log(mapped);      // [2, empty, 6] — hole preserved
console.log(1 in mapped); // false — not undefined, actually missing

Return Semantics and Chaining

Because map() returns an array, it composes with the rest of the array toolkit. This is the idiomatic data-pipeline style:

const total = orders
  .filter(o => o.status === 'paid')
  .map(o => o.total)
  .reduce((sum, t) => sum + t, 0);

forEach() is always a dead end: it returns undefined, so nothing can be chained after it. If you find yourself writing forEach in the middle of a pipeline, the design is wrong.

Note that every chained stage above allocates an intermediate array. For small and medium collections this is irrelevant. If you routinely process hundreds of thousands of items, the iterator helpers standardized in recent ECMAScript versions give you the same pipeline lazily, with no intermediate arrays:

const total = orders.values()
  .filter(o => o.status === 'paid')
  .map(o => o.total)
  .reduce((sum, t) => sum + t, 0);

The Async Pitfall

This is the most damaging real-world difference. An async callback returns a promise, and the two methods treat that promise very differently.

With map(), you get an array of promises — useful, as long as you remember to await them:

// Correct: parallel requests, resolved together
const users = await Promise.all(ids.map(id => fetchUser(id)));

Forgetting Promise.all leaves you holding [Promise, Promise, ...], which often survives until some distant piece of code logs [object Promise].

With forEach(), the returned promises are silently discarded. There is no way to know when the work finished, errors become unhandled rejections, and iterations run concurrently rather than in order:

// BROKEN: logs 'done' before any save completes,
// and a failed save() crashes nothing visibly
items.forEach(async item => {
  await save(item);
});
console.log('done');

The fixes:

// Sequential — each save waits for the previous one
for (const item of items) {
  await save(item);
}

// Parallel — all saves at once, failures propagate
await Promise.all(items.map(item => save(item)));

Rule of thumb: never pass an async function to forEach().

Common Bugs

1. Using map() for side effects only

// BAD — allocates an array nobody reads
users.map(u => console.log(u.name));

// GOOD
users.forEach(u => console.log(u.name));

The ESLint rule array-callback-return flags this.

2. Forgetting to return from a block body

const names = users.map(u => { u.name; });
console.log(names); // [undefined, undefined, ...]

An arrow function with braces needs an explicit return. Either drop the braces or return explicitly.

3. The parseInt trap

['1', '7', '11'].map(parseInt); // [1, NaN, 3]

map() passes (value, index, array) to the callback, and parseInt(string, radix) interprets the index as a radix: parseInt('7', 1) is NaN, parseInt('11', 2) is 3. Fix it by controlling the arguments:

['1', '7', '11'].map(s => parseInt(s, 10)); // [1, 7, 11]
['1', '7', '11'].map(Number);               // [1, 7, 11]

4. Trying to break out

Neither method supports break or continue. Throwing an exception to escape is a code smell. When you need early exit, reach for for...of, some(), every(), find(), or findIndex().

5. Mutating the array inside the callback

Because the length is snapshotted and indexes are visited in order, pushing, splicing, or deleting during iteration produces skipped or stale elements. Treat the source array as read-only inside both callbacks.

Performance

For typical workloads the difference is noise. What actually matters:

  • map() allocates one result array; the engine knows the final length up front, so the allocation is cheap.
  • forEach() allocates nothing, so it wins slightly when you genuinely do not need a result.
  • A plain for loop still beats both in tight hot paths because there is no callback invocation per element — relevant for game loops and parsers, irrelevant for a 200-item render list.
  • In long chains over large arrays, intermediate allocations dominate; prefer a single reduce() or iterator helpers there.

Never rewrite map() into a for loop "for speed" without profiling first. Readability usually pays better than the microseconds.

Comparison at a Glance

Feature map() forEach()
Return value New array undefined
Chainable Yes No
Skips holes Yes (preserves them) Yes
Works with async Yes, via Promise.all No — promises discarded
Early exit No No
Intended use Transformation Side effects

Recommendations

  • Need a transformed array? Use map() and use its result.
  • Just doing something per element? Use forEach() or a for...of loop.
  • Awaiting inside the loop? Use Promise.all(arr.map(fn)) for parallel work, for...of for sequential — never forEach.
  • Need to stop early? Use for...of, some(), or find().
  • Turn on array-callback-return in ESLint to catch the missing-return and map-as-forEach mistakes automatically.

The one-question test still works: "Do I need the result as an array?" Yes means map(); no means forEach() — and "yes, but each result is a promise" means map() plus Promise.all.