~/blog

forEach does not await your async callback

published

#javascript#async#node

TL;DR

array.forEach(async (x) => { await work(x) }) starts every call and returns undefined immediately. MDN is explicit: “forEach() expects a synchronous function — it does not wait for promises.” Use for...of with await when you need order, or await Promise.all(array.map(fn)) when you don’t. The same trap sinks map, filter, some and every with async predicates.

The problem

This looks like it processes every file and then logs:

const files = ['a.json', 'b.json', 'c.json'];

files.forEach(async (name) => {
  const text = await readFile(name, 'utf8');
  await save(JSON.parse(text));
});

console.log('all saved');

all saved prints before a single file is read. If the next line is process.exit(0), or the end of a serverless handler, or a res.end(), the writes are cut off mid-flight — and nothing throws. In a request handler you get a 200 for work that never finished.

Worse, an error inside the callback does not reach the surrounding try:

try {
  files.forEach(async (name) => {
    throw new Error('boom');
  });
} catch (err) {
  console.log('caught', err.message); // never runs
}

Node prints [UnhandledPromiseRejection] and, since v15, exits with a non-zero code — but from somewhere unrelated to your try, often long after the function that caused it returned.

Why it happens

An async function always returns a promise, and forEach throws that promise away. MDN says of the callback: “Its return value is discarded.” Of forEach itself: “Unlike map(), forEach() always returns undefined and is not chainable.”

So the sequence is:

  1. forEach calls the callback for element 0. The callback runs synchronously up to its first await, then returns a pending promise.
  2. forEach ignores that promise and immediately calls the callback for element 1. Same thing.
  3. forEach returns undefined. Every callback is still suspended at its first await.
  4. Your next statement runs. The work resumes later, on microtask turns, with nobody holding a reference to it.

Nothing here is a bug in forEachthe spec defines it as calling callbackfn and discarding the result. forEach predates promises and was never given a way to wait.

The same discard rule breaks the other iterative methods in a nastier way, because a promise is always truthy:

CallWhat you meantWhat you get
arr.forEach(async fn)run each, then continuecontinues immediately, work still pending
arr.map(async fn)array of resultsarray of promises
arr.filter(async fn)matching elementsevery element — a pending promise is truthy
arr.some(async fn)true if any matchtrue for any non-empty array
arr.every(async fn)true if all matchtrue for any array

filter is the quiet one: it does not crash, it does not warn, it just stops filtering.

What to do

Sequential — you need order, or you are rate-limited:

for (const name of files) {
  const text = await readFile(name, 'utf8');
  await save(JSON.parse(text));
}

for...of awaits each iteration before starting the next, and a throw propagates to the enclosing try.

Parallel — the items are independent:

await Promise.all(
  files.map(async (name) => {
    const text = await readFile(name, 'utf8');
    await save(JSON.parse(text));
  }),
);

map keeps the promises instead of discarding them, and Promise.all waits for all of them and rejects on the first failure.

Parallel, but you want every result even if some fail:

const results = await Promise.allSettled(files.map(process));
const failed = results.filter((r) => r.status === 'rejected');

Promise.all rejects as soon as one promise rejects, but the others keep running — it does not cancel them. If a rejection means “stop the rest”, you need an AbortController, not just Promise.all.

Async filter — do the async part first, then filter synchronously:

const flags = await Promise.all(files.map((f) => isReadable(f)));
const readable = files.filter((_, i) => flags[i]);

Catch it in CI, not in production. If you use typescript-eslint, no-misused-promises flags passing an async function where a void-returning one is expected — which is exactly this call shape. It needs type information, so it only runs on a type-aware config.

Caveats

References