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 withundefined. map()creates its result with the species constructor. If you subclassArray,map()returns an instance of your subclass, whileforEach()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
forloop 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...ofloop. - Awaiting inside the loop? Use
Promise.all(arr.map(fn))for parallel work,for...offor sequential — neverforEach. - Need to stop early? Use
for...of,some(), orfind(). - Turn on
array-callback-returnin 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.