You save three products, then display “all done.” Yet the completion message appears before any save finishes. The code contains await, so what happened? The key question is who awaits the Promises returned by the callbacks.
The saveItem() function below simulates a server save with a short delay. We will use the same IDs, 1, 2, 3, to compare forEach, for...of, and Promise.all.
Why does the message after forEach(async ...) appear first?
forEach calls the callback for every element and returns undefined. It does not collect and await the Promises returned by an async callback. An await inside that callback only suspends that callback. MDN documents this behavior.
The diagram compares the completion boundary. In this example the deliberately different delays make saves finish in the order 3, 2, 1; for...of with await moves to the next ID only after the previous save completes.
const ids = [1, 2, 3];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function saveItem(id) {
await sleep((4 - id) * 10);
console.log(`saved:${id}`);
return id;
}
ids.forEach(async (id) => {
await saveItem(id);
});
console.log('all done?');Here all done? prints first, followed by saved:3, saved:2, and saved:1. The delays were chosen to expose the ordering difference; real network completion order is not predictable. Adding await before ids.forEach(...) does not help because forEach returns undefined, not a Promise representing the saves. Errors from those callbacks also need their own handling.
When order matters, what does for...of await?
Keep ids and saveItem() from the first example, but replace the loop:
for (const id of ids) {
await saveItem(id);
}
console.log('all done');Now the output is saved:1 → saved:2 → saved:3 → all done. Each iteration finishes before the next starts. Use this when a later save depends on an earlier one or when request order is part of the contract. Independent requests take longer when processed strictly one at a time.
If saveItem(id) rejects, the loop exits and control reaches an outer catch unless you handle the error inside the loop. To attempt the remaining IDs, use per-item try/catch and record failures. Do not silently display “all done” if some saves failed.
How do you start independent work and await all of it?
When save order does not matter but every result does, create the Promises and await their aggregate:
const results = await Promise.all(ids.map((id) => saveItem(id)));
console.log('all done');
console.log(results); // [1, 2, 3]All three saves start before Promise.all settles. They may finish 3, 2, 1, but results retains input order. If one rejects, the aggregate rejects; the other saves that have already started are not automatically cancelled. Retrying the whole group can repeat successful writes, so a real save API needs a duplicate or idempotency policy. For thousands of requests, also limit concurrency instead of starting all of them at once.
Which iteration matches the desired completion rule?
| Desired behavior | Choice | Meaning of completion |
|---|---|---|
| Start each job without awaiting its result | forEach callback | Loop end does not mean jobs are done |
| Start the next job after the previous one finishes | for...of + await | After the last awaited job |
| Start independent jobs together and wait for all | Promise.all(ids.map(...)) | After every Promise fulfills |
Before sending real requests, decide whether order matters, what to do when one fails, and whether a retry can duplicate a write. The right loop follows from those requirements.
Key takeaways
The await in forEach(async ...) waits only inside each callback. The surrounding forEach does not await those callbacks, so a completion message can run first. Use for...of for sequential work and Promise.all to await independent work together. Define failure and retry behavior before claiming that everything has finished.

