forEach does not await your async callback
published
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:
forEachcalls the callback for element 0. The callback runs synchronously up to its firstawait, then returns a pending promise.forEachignores that promise and immediately calls the callback for element 1. Same thing.forEachreturnsundefined. Every callback is still suspended at its firstawait.- Your next statement runs. The work resumes later, on microtask turns, with nobody holding a reference to it.
Nothing here is a bug in forEach — the 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:
| Call | What you meant | What you get |
|---|---|---|
arr.forEach(async fn) | run each, then continue | continues immediately, work still pending |
arr.map(async fn) | array of results | array of promises |
arr.filter(async fn) | matching elements | every element — a pending promise is truthy |
arr.some(async fn) | true if any match | true for any non-empty array |
arr.every(async fn) | true if all match | true 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
- This is not an argument against
forEachgenerally. With a synchronous callback it is fine; the trap is specific to async callbacks. - Fire-and-forget is a legitimate pattern — but then make it explicit and attach a handler, e.g.
void fn().catch(report), so a rejection is reported rather than unhandled. Promise.allon a large array starts everything at once. Thousands of concurrent connections will hit socket or file-descriptor limits; batch or bound the concurrency when the array is unbounded.for await...ofis for async iterables (streams, async generators), not for addingawaitto a plain array loop — a plainfor...ofwithawaitinside is what you want there.- Top-level
awaitin ESM has the same shape of hazard in reverse: it delays module evaluation. Unrelated mechanism, do not conflate them.