skip to content

A migration script runs `ids.forEach(async (id) => { await save(id); });` and then logs "done", but "done" appears before any save finishes and failures never reach the surrounding try/catch. Why does that happen, and how do you fix it?

level: seniorimportance: must knowfreq 62%

answer

  1. an async callback returns a promise
  2. forEach throws return values away
  3. nothing is holding the promise
  4. awaiting undefined resolves instantly
  5. for...of has no callback boundary

basics

~20 s

An async callback returns a promise, and forEach discards its callback's return value, so forEach starts every call and returns undefined immediately without waiting. Rejections surface as unhandled rejections. Use for...of with await for sequential work, or await Promise.all over a mapped array for concurrent work.

solid answer

~40 s

Marking the callback `async` makes it return a promise the moment it hits the first `await`. `forEach` ignores callback return values by contract, so it fires all three calls, gets three pending promises, throws them away, and returns `undefined` — synchronously. Your `"done"` log runs while every `save` is still in flight, and because nothing is awaiting or `.catch`-ing those promises, a rejection becomes an unhandled rejection rather than an error your `try/catch` sees. `await ids.forEach(...)` does not help either: it awaits `undefined`. The fix is to use a construct that actually holds the promise. `for (const id of ids) { await save(id); }` runs them one at a time inside your function, so `try/catch` works normally. If you want them concurrent, collect the promises and await them together: `await Promise.all(ids.map((id) => save(id)))`.

code

javascript · 19 lines
javascript
const ids = [1, 2, 3];
const save = (id) => new Promise((r) => setTimeout(() => r(id), 10));

async function broken() {
  ids.forEach(async (id) => { await save(id); });
  console.log('broken: done'); // logs while saves are still pending
}

async function sequential() {
  for (const id of ids) await save(id);
  console.log('sequential: done');
}

async function concurrent() {
  await Promise.all(ids.map((id) => save(id)));
  console.log('concurrent: done');
}

broken().then(sequential).then(concurrent);

go deeper

for a junior

Recognise that putting async inside forEach does not make the loop wait, and that the safe default is for...of with await in an async function.

for a middle

Explain the mechanism: the async callback returns a promise at the first await, forEach discards return values, so it returns undefined synchronously and the rejections have no handler.

for a senior

Diagnose it from symptoms — a completion log that precedes the work, unhandled-rejection stacks originating in a callback, a burst of simultaneous outbound calls — and choose between sequential and concurrent fixes on downstream limits, not style.

for a principal

Own the prevention: lint rules that reject async callbacks in forEach, unhandled rejections configured to fail loudly in CI, and a stated convention for how the codebase bounds concurrency on large inputs.

## The mechanism, in one line `forEach` discards whatever its callback returns. An `async` function returns a promise. Those two facts together mean `forEach` starts asynchronous work and then immediately forgets about it. ## Walking the execution ```js async function migrate(ids) { try { ids.forEach(async (id) => { await save(id); }); console.log('done'); } catch (err) { console.error('failed', err); // never reached } } ``` Step by step: 1. `forEach` calls the callback with `ids[0]`. The callback body runs synchronously until the first `await`, which suspends it and **returns a pending promise** to `forEach`. 2. `forEach` ignores that promise and immediately calls the callback with `ids[1]`, then `ids[2]`. Now three suspended callbacks and three orphaned promises exist. 3. `forEach` returns `undefined`. 4. `console.log('done')` runs. Nothing has finished. 5. Later, each `save` settles. If one rejects, its promise has no handler and no awaiting parent, so the runtime reports an unhandled rejection — in Node that terminates the process by default on modern versions; in a browser it fires `unhandledrejection`. Either way your `catch` block never sees it. The damage in a migration script is real: you report success before the work is done, you have no idea which records actually landed, and you fire every request at the database at once instead of at a controlled rate. ## Why `await ids.forEach(...)` does not save you ```js await ids.forEach(async (id) => { await save(id); }); // still broken ``` `forEach` returns `undefined`. Awaiting a non-promise resolves on the next microtask tick, so this adds a negligible delay and nothing else. The presence of `await` is what makes this version so dangerous in review: it *looks* correct. ## Fix one: sequential with for...of ```js for (const id of ids) { await save(id); } console.log('done'); // now truly done ``` `for...of` has no callback boundary — the `await` belongs to the enclosing async function, so the function itself suspends at each step. `try/catch` around this behaves exactly as you would expect, and a rejection stops the loop at the failing element. This is the right shape when order matters, when each step depends on the previous one, or when you deliberately do not want to hammer a downstream system. ## Fix two: concurrent with mapped promises ```js await Promise.all(ids.map((id) => save(id))); console.log('done'); ``` Here `map` **does** keep the callback's return value, so you get an array of promises, and awaiting them together makes the function wait for all of them. Note the explicit arrow: writing `ids.map(save)` passes the index and the array as second and third arguments to `save`, which is its own classic bug if `save` accepts more than one parameter. ## Diagnosing it in the wild Symptoms that point straight at this pattern: - A completion log or a resolved response arrives before the work it claims to have completed. - `UnhandledPromiseRejection` in logs with a stack that starts inside a callback, not inside your `try`. - Downstream code reading data that "should" exist and finding it missing on the first run and present on a retry. - A burst of simultaneous outbound requests where the code reads as if it were serial. When you see `forEach(async` in a diff, treat it as a defect on sight. The same reasoning applies to any method that ignores callback return values — `map` is safe because it collects them, `forEach` is not. ## The judgment part Once you have fixed the correctness bug, the remaining question is which fix. Sequential `for...of` is predictable, easy to reason about under failure, and gentle on the downstream system, but slow for large inputs. `Promise.all` over a mapped array is fast but starts everything at once, which for a few thousand ids is a good way to exhaust a connection pool or trip a rate limit. The honest answer in an interview is that you pick based on whether the operations are independent and whether the downstream system can absorb the concurrency — and that for large inputs you want a bounded degree of concurrency rather than either extreme.

  • Why does `map` work here when `forEach` does not?
    Because `map` keeps what the callback returns — with an async callback that means an array of promises, which you can then hand to `await Promise.all(...)`. `forEach` discards return values by contract and hands back `undefined`, so there is nothing left to await. The difference is entirely about whether the method retains the promise or drops it.
  • Why is `await ids.forEach(async (id) => save(id))` still wrong even though it has an await?
    `forEach` returns `undefined`, so you are awaiting a non-promise. That resolves on the next microtask and continues, while the saves are still running. It is more dangerous than the version without `await`, because the keyword makes reviewers assume the waiting is handled.
  • What is the operational risk of switching straight from the broken forEach to Promise.all?
    It launches every operation at once. For a handful of items that is fine; for thousands it can exhaust a connection pool, trip a rate limiter, or blow memory holding all the in-flight state. If the input size is unbounded, you want a bounded number in flight rather than all-at-once, and you should decide that from the downstream system's limits, not from the code shape.
  • How would you stop this pattern reaching production again?
    Lint for it — rules that flag async functions passed to `forEach` exist and catch it at the diff. Beyond that, make unhandled rejections fatal in your runtime configuration so the symptom is loud in test rather than silent in production, and treat a completion log that can print before its work as a review-blocking defect.

saying these in an interview costs you the question

  • Thinks awaiting the forEach call fixes it
  • Says forEach waits for async callbacks
  • Believes try/catch catches the rejection
  • Claims map has the same problem as forEach
  • Says the fix is only to add more awaits

context