Short version: use map when you want a new array built from the old one, and use forEach when you want to do something for each item and don’t need anything back. forEach always returns undefined. When you need to stop early, await inside the loop, or iterate something that isn’t an array, reach for for...of.
Interviewers ask this because the wrong choice usually still “works”. Code that pushes into an outer array from forEach, or calls map and throws the result away, runs fine and passes review in a hurry. What they want to hear is that you pick the method that says what the code means.
How the three loops compare
map |
forEach |
for...of |
|
|---|---|---|---|
| Returns | New array, same length | undefined |
Nothing (it’s a statement) |
| Purpose | Transform values | Side effects | General iteration |
Chainable (.filter, .reduce) |
Yes | No | No |
break / continue |
No | No (return only skips the current item) |
Yes |
Waits for await between items |
No | No | Yes, inside an async function |
| Works on | Arrays (and typed arrays) | Arrays, Map, Set, NodeList |
Any iterable: arrays, strings, Map, Set, generators |
| Holes in sparse arrays | Skipped, holes kept in the output | Skipped | Visited as undefined |
| Callback gets index | Yes, (value, index, array) |
Yes | Use array.entries() |
The return value is the whole difference
Run both on the same array in Node 22:
const prices = [10, 25, 40];
const a = prices.map((p) => p * 1.2);
const b = prices.forEach((p) => p * 1.2);
console.log(a, b);[ 12, 30, 48 ] undefinedmap collects whatever the callback returns, in order, into a new array. The original prices array is untouched. forEach calls the same callback and discards the return value, which is why b is undefined. It isn’t a bug in forEach; it just has no result to give.
That leads to two common mistakes, one in each direction.
Using forEach to build an array. This works:
const withTax = [];
prices.forEach((p) => withTax.push(p * 1.2));It produces the same [12, 30, 48], but you’ve written three moving parts (an empty array, a closure that mutates it, a push) where map needs one expression. Reviewers flag it because the reader has to trace the mutation to learn the shape of the result.
Using map for side effects. The reverse also runs:
const res = prices.map((p) => { console.log('saving', p); });
console.log(res);saving 10
saving 25
saving 40
[ undefined, undefined, undefined ]The braces mean the arrow function has a body with no return, so every slot is undefined. You’ve allocated an array nobody wants, and anyone reading map expects a transformation. If the point is logging or saving, forEach or for...of says so.
Because forEach returns undefined, chaining after it fails at runtime:
TypeError: Cannot read properties of undefined (reading 'filter')That’s the error you get from prices.forEach(...).filter(...). With map, chaining is the normal style: prices.map((p) => p * 2).filter((p) => p > 30) gave [ 50, 80 ].
Stopping early: only for…of can break
There’s no way to break out of forEach or map. A return inside the callback ends that one call and moves on to the next item:
for (const p of prices) { if (p > 20) break; console.log('for...of saw', p); }
prices.forEach((p) => { if (p > 20) return; console.log('return only skips', p); });for...of saw 10
return only skips 10The output looks identical here, but forEach still called the callback for 25 and 40. With a million items, or a callback that does real work, that difference matters. Throwing an exception does stop forEach (the error “stop at 25” was printed when I tried it), but using exceptions for control flow is a smell interviewers will call out.
If you’re searching, the right tool is often neither loop. users.find((u) => !u.active) returns the first match and stops, and some/every short-circuit too. Mentioning those shows you know the rest of the array API.
Holes, mutation and the parseInt trap
Sparse arrays. [1, , 3] has a hole at index 1, not an undefined value. map skips the callback for holes and keeps the hole in the output (Node prints [ 2, <1 empty item>, 6 ]). forEach ran its callback twice, not three times. for...of uses the array iterator, which reads every index, so it ran three times and logged undefined for the hole. You won’t meet holes often, but they appear with new Array(5) and delete arr[i], and they explain why new Array(3).map(() => 0) gives you three empty slots instead of zeros (use Array.from({ length: 3 }, () => 0)).
Adding items mid-loop. forEach and map decide how many indexes to visit before the first callback runs. Pushing 99 onto the array during a forEach left the array as [ 1, 2, 3, 99 ], but the callback never saw 99. A for...of loop over an array checks the length on each step, so it would keep going, and an unconditional push would make it loop forever.
map(parseInt). This is a favourite puzzle:
console.log(['1', '7', '11'].map(parseInt));
console.log(['1', '7', '11'].map(Number));[ 1, NaN, 3 ]
[ 1, 7, 11 ]map passes three arguments: value, index and array. parseInt takes two: string and radix. So the calls are parseInt('1', 0) (radix 0 means “guess”, giving 1), parseInt('7', 1) (radix 1 is invalid, giving NaN) and parseInt('11', 2) (binary 11 is 3). Number ignores extra arguments. The lesson is wider than parseInt: passing a function reference straight into map passes the index too.
A realistic example: building select options
Say an API returns users and you need dropdown options for the active ones, plus a lookup table by id:
const users = [
{ id: 1, first: 'Ada', last: 'Lovelace', active: true },
{ id: 2, first: 'Alan', last: 'Turing', active: false },
{ id: 3, first: 'Grace', last: 'Hopper', active: true },
];
const options = users
.filter((u) => u.active)
.map((u) => ({ value: u.id, label: `${u.first} ${u.last}` }));
const byId = new Map();
users.forEach((u) => byId.set(u.id, u));options came out as [{ value: 1, label: 'Ada Lovelace' }, { value: 3, label: 'Grace Hopper' }], and byId.get(3).first was 'Grace'. Each method matches its job: filter and map derive a new array (this is also exactly how a React component renders a list), while forEach fills a structure that already exists. You could also write new Map(users.map((u) => [u.id, u])), which avoids the mutation entirely.
On performance: a plain for loop can be faster in micro-benchmarks, but for everyday array sizes the difference rarely shows up in a profile. Pick for clarity first and measure before swapping loops for speed.
Async callbacks change the answer
map with an async callback returns an array of promises ([ Promise { 20 }, Promise { 50 }, Promise { 80 } ] in the test run), which you can pass to Promise.all to run the work in parallel. forEach with an async callback fires every call and doesn’t wait for any of them, so code after the loop runs too early. for...of with await runs items one at a time. The details, including limiting concurrency, are in async/await in loops.
Interview exercise: spot the problem
A candidate writes this to get the names of users over 18:
const names = [];
users.map((u) => { if (u.age > 18) names.push(u.name); });What’s wrong, and how would you rewrite it?
Answer
It produces the right names, so the problem is intent, not output. map builds and returns an array that’s thrown away (full of undefined), and the real result comes from a side effect on an outer variable. A reader sees map and expects a transformation of every user, which isn’t what happens. The idiomatic version is const names = users.filter((u) => u.age > 18).map((u) => u.name);, which needs no outer state and no mutation. If performance on huge arrays mattered, a single for...of loop that pushes would avoid the intermediate array, and that’s a reasonable trade to mention as long as you say why.