skip to content

In JavaScript, `const results = items.map(async (item) => save(item));` gives an array of pending promises rather than saved results, and `items.forEach(async (item) => { await save(item); });` returns before anything has been saved. Explain both behaviours and give the correct way to save every item.

level: middleimportance: should knowfreq 55%

answer

  1. array methods know nothing about promises
  2. an async callback always returns a promise
  3. map keeps them, forEach throws them away
  4. a pending promise is always truthy
  5. wrap the mapped array in Promise.all

basics

~20 s

An async callback always returns a promise, so map collects promises and you must await them with Promise.all. forEach discards whatever its callback returns, so nothing waits for the saves and any rejection becomes unhandled. Use await Promise.all(items.map(...)).

solid answer

~40 s

Both come from the same fact: an `async` function returns a promise, always. `map` faithfully collects those promises, so `results` is an array of pending promises rather than values — the fix is `const results = await Promise.all(items.map((item) => save(item)))`. `forEach` is worse, because it ignores its callback's return value entirely: the callbacks all start, `forEach` returns `undefined` immediately, and the enclosing function proceeds as if the work were done. A rejection in one of those callbacks has nothing attached to it and surfaces as an unhandled rejection. So `forEach` with an async callback is essentially never what you want. If the items are independent, use `map` plus `Promise.all`; if each save must complete before the next begins, use a plain `for...of` loop with `await` in the body.

code

javascript · 29 lines
javascript
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const save = async (item) => {
  await sleep(50);
  return { ...item, saved: true };
};

const items = [{ id: 1 }, { id: 2 }, { id: 3 }];

(async () => {
  // Wrong: forEach discards the promises
  items.forEach(async (item) => {
    await save(item);
  });
  console.log('after forEach — nothing saved yet');

  // Wrong: map without Promise.all
  const pending = items.map(async (item) => save(item));
  console.log('after map:', pending[0] instanceof Promise); // true

  // Right: concurrent, results in input order
  const saved = await Promise.all(items.map((item) => save(item)));
  console.log('saved:', saved.length);

  // Right: strictly one at a time
  for (const item of items) {
    await save(item);
  }
  console.log('sequential pass complete');
})();

go deeper

for a junior

Know that any async function returns a promise, so map with an async callback gives you promises and you must wrap the result in Promise.all. Never pass an async callback to forEach.

for a middle

Explain that array methods are promise-unaware: map collects the returned promises while forEach discards them, which is why one is salvageable and the other is not. Be able to name the filter truthiness trap too.

for a senior

Describe the production consequence: a forEach with async callbacks lets a handler respond or a process exit while writes are still in flight, and turns failures into unhandled rejections that can kill a Node process. Say how you would prevent it — a lint rule plus a review habit.

for a principal

Own the standard: which shape the codebase uses for per-item async work, whether the fan-out from a mapped collection needs a concurrency ceiling, and how the team enforces it automatically rather than by review vigilance.

## One rule explains both symptoms An `async` function returns a promise no matter what its body does. It cannot return a plain value, because the caller is handed the promise the moment the body reaches its first `await` — long before any result exists. Array methods know nothing about promises; they just call your function and do whatever they normally do with the value it returns. Every surprise in this area follows from combining those two facts. ## `map` collects promises, not results ```js const results = items.map(async (item) => save(item)); console.log(results); // [Promise { <pending> }, Promise { <pending> }, ...] ``` This is not a bug in `map`; it is `map` working exactly as specified. The useful part is that all the callbacks have already been invoked, so every `save` is already in flight — you just need to wait for the collection: ```js const results = await Promise.all(items.map((item) => save(item))); ``` Note the callback here does not need to be `async` at all if it only forwards the promise. Make it `async` when there are several steps per item: ```js const rows = await Promise.all( items.map(async (item) => { const saved = await save(item); return { id: saved.id, ok: true }; }) ); ``` Awaits *inside* one callback sequence only that item's own steps; different callbacks still overlap with each other. ## `forEach` throws the promise away `forEach` is specified to ignore its callback's return value and always evaluate to `undefined`. With an async callback that means: ```js items.forEach(async (item) => { await save(item); }); console.log('done'); // logs immediately — nothing has been saved yet ``` Three things go wrong. First, the enclosing function continues as if the work were complete, so anything downstream — closing a connection, sending a response, ending a test — happens too early. Second, there is no way to know when the saves finish, because the only handles to them were discarded. Third, if one `save` rejects, that rejection has no handler attached and is reported as an unhandled rejection; in Node that terminates the process by default. A `try`/`catch` around the `forEach` call catches none of it, because the callback's failure never travels back through `forEach`. ## The other array methods `filter` is the quietest trap. A predicate that is async returns a promise, and every promise object is truthy, so the filter keeps everything: ```js [1, 2, 3].filter(async (n) => n > 2); // [1, 2, 3] ``` The workaround is to compute the decisions first and then filter synchronously: ```js const keep = await Promise.all(items.map((item) => isValid(item))); const valid = items.filter((_, i) => keep[i]); ``` `sort` has the same problem — an async comparator returns a promise, which coerces to `NaN` when compared numerically, producing an implementation-defined order. Resolve the sort keys first, then sort synchronously. `some` and `every` fail the same way, since a pending promise is always truthy. ## When you actually want sequencing If each item must be processed strictly after the previous one, a plain loop is the clearest tool, because `await` in a loop body does exactly that: ```js for (const item of items) { await save(item); } ``` The `reduce`-based chain achieves the same thing when you prefer an expression, though it is harder to read: ```js await items.reduce((prev, item) => prev.then(() => save(item)), Promise.resolve()); ``` ## The rule of thumb Only `map` combines cleanly with async callbacks, and only because you then hand the resulting array to `Promise.all`. For anything else, either resolve the values first and use the array method synchronously, or write the loop. And treat an `async` callback passed to `forEach` as a defect on sight — a linter rule for it pays for itself.

  • Why does `items.filter(async (item) => item.active)` return every item?
    Because the async callback returns a promise, not a boolean, and every promise object is truthy — so `filter` keeps every element. Resolve the decisions first with `Promise.all(items.map(...))`, then filter the original array synchronously using the resulting boolean array by index.
  • If forEach is wrong, what does `await Promise.all(items.map(...))` do differently about errors?
    It attaches reactions to every promise immediately and folds them into one promise, so a rejection propagates to your `await` and can be caught with a normal try/catch. With `forEach`, the promises are dropped, so a rejection has no handler and is reported as an unhandled rejection instead.
  • Is `items.map(async (item) => await save(item))` different from `items.map((item) => save(item))`?
    Functionally no — both produce an array of promises that settle with the same values, and both start every call immediately. The `async`/`await` version adds a redundant microtask hop per item. Use the async form when the callback does several steps or needs its own try/catch; otherwise the plain form is clearer.

saying these in an interview costs you the question

  • forEach with an async callback awaits each item
  • map with an async callback returns the resolved values
  • An async filter predicate filters by the resolved boolean
  • try/catch around forEach catches the callback's rejections
  • Adding async to a callback makes an array method promise-aware

context