Awaiting ForEach Finishes Too Early: Own the Callback Promises Explicitly
By ON Lab · Sources checked November 8, 2026
Array.forEach does not wait for promises returned by its callback. Awaiting forEach itself therefore does not wait for those asynchronous operations. Use a for...of loop when each operation must finish before the next starts, or await Promise.all over map when the operations are independent and bounded.
Make the early continuation visible
A public JavaScript question asks why async/await inside a forEach does not produce the expected completed loop. This synthetic example runs in a Node .mjs file and uses only timers:
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const done = [];
const returned = await [1, 2].forEach(async value => {
await delay(10);
done.push(value);
});
console.log(returned);
console.log([...done]);
await delay(30);
console.log([...done]);In our Node 24.22.2 fixture, the forEach return value was undefined and the immediate result was empty. The later observation contained both completed values. The loop had started its callbacks, but awaiting its return value had not collected or awaited their promises.
For sequential work, await inside for...of
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const results = [];
for (const value of [1, 2]) {
await delay(2);
results.push(value * 2);
}
console.log(results);This produces the results after each iteration completes. Our corresponding trace was start 1, done 1, start 2, done 2. It fits a contract in which later work must not begin before earlier work finishes. Put error handling around the awaited operation according to whether a failure should stop or continue the sequence.
For independent work, collect the promises
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const results = await Promise.all([1, 2].map(async value => {
await delay(value === 1 ? 10 : 1);
return value * 2;
}));
console.log(results);map returns the promises for Promise.all to observe. The result array retains input order even when completion order differs. In our separate fixture, task two finished before task one, while the values still came back in input order. This is a different execution policy from the sequential loop, not merely a shorter spelling.
A rejection does not cancel the other operations
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
try {
await Promise.all([
Promise.reject(new Error("sample failure")),
delay(5).then(() => console.log("other operation completed"))
]);
} catch (error) {
console.log("caught", error.message);
}
await delay(20);Our fixture caught the failure and then observed the other operation complete. Promise.all’s rejection is not a rollback or a cancellation request for work already started. With real side effects, design ordering, cancellation and recovery separately rather than assuming the catch block prevented every action.
Choose by the real work contract
| Requirement | Suitable starting point |
|---|---|
| Next task must wait for the previous result | for...of with an awaited operation |
| Small independent tasks can run together | map plus awaited Promise.all |
| Every success and failure must be collected | Consider an explicit result policy, including Promise.allSettled where appropriate |
| A large or unbounded input must respect a resource limit | Use a reviewed concurrency limit; do not start every operation at once |
The examples do not implement a concurrency pool or certify a rate limit. They show the promise-ownership boundary with two local tasks. A real service’s request and retry policy needs its own tests.
Verify the caller’s completion point
Put an observation immediately after the await at the place where completed data will be consumed. Inspect both the returned value and the operation trace. A print inside an async callback proves only that the callback reached that print; it does not establish that the caller waited for all callbacks.
Do not silence errors with a catch that returns a success-shaped empty result. Preserve whether the operation failed and whether other work was already started. The standard language APIs and original public answers are free alternatives; no additional async library is required for these examples.
Method: current MDN documentation, a historical public JavaScript question and four Node 24.22.2 timer fixtures. No service request, database write, real retry, cancellation mechanism or unlimited workload was tested.