JavaScript await forEach Does Not Wait: Choose Serial or Concurrent Work
await items.forEach(async item => { await save(item); }) does not wait for every save. The callback pauses at its own await, but forEach() ignores the promise it returns. Code after the outer await can run while the saves are still pending.
Use a for...of loop with await when each operation must finish before the next starts. Use await Promise.all(items.map(...)) when independent operations may overlap and you need their results. These choices change when work starts as well as when the caller can continue.
Why the outer await completes early
The ECMAScript forEach algorithm calls each callback and returns undefined. It does not collect callback results. An async callback returns a promise, so an enclosing try/catch around await forEach(...) also cannot catch that callback’s rejected promise. Handle it within the callback or, preferably for a batch, return and await the task promises.
MDN documents that forEach expects synchronous callbacks. Adding another await around it does not supply the missing completion promise. Awaiting a non-promise value still defers continuation, but it does not discover asynchronous work launched elsewhere.
Verify completion and overlap without timers
Save the following as async-loop.mjs and run node async-loop.mjs. This exact fixture was executed during article preparation on macOS with Node.js 24.13.0, on October 11, 2026. It needs no packages.
A gate holds both operations before completion. The fixture checks whether the batch has returned while the gate remains closed, then releases it and checks the event order and returned values. Capturing tasks is test instrumentation to drain the deliberately ignored work; it is not a repair to the forEach expression.
import assert from 'node:assert/strict';
async function inspect(mode) { let release; const gate = new Promise(resolve => { release = resolve; }); const events = []; const tasks = []; let returned = false; const items = ['A', 'B'];
function work(item) { const task = (async () => { events.push(`start:${item}`); await gate; events.push(`end:${item}`); return item.toLowerCase(); })(); tasks.push(task); // Test instrumentation: lets us drain ignored tasks. return task; }
const batch = (async () => { let result; if (mode === 'forEach') { result = await items.forEach(async item => { await work(item); }); } else if (mode === 'serial') { result = []; for (const item of items) result.push(await work(item)); } else { result = await Promise.all(items.map(work)); } returned = true; return result; })();
await Promise.resolve(); const before = { returned, events: [...events] }; release(); const result = await batch; await Promise.all(tasks);
const serial = mode === 'serial'; assert.equal(before.returned, mode === 'forEach'); assert.deepEqual(before.events, serial ? ['start:A'] : ['start:A', 'start:B']); assert.deepEqual(events, serial ? ['start:A', 'end:A', 'start:B', 'end:B'] : ['start:A', 'start:B', 'end:A', 'end:B']); assert.deepEqual(result, mode === 'forEach' ? undefined : ['a', 'b']);
console.log(`${mode} before: ${JSON.stringify(before)}`); console.log(`${mode} after: ${JSON.stringify({ events, result: result ?? null })}`);}
for (const mode of ['forEach', 'serial', 'concurrent']) await inspect(mode);Expected output for that environment:
forEach before: {"returned":true,"events":["start:A","start:B"]}forEach after: {"events":["start:A","start:B","end:A","end:B"],"result":null}serial before: {"returned":false,"events":["start:A"]}serial after: {"events":["start:A","end:A","start:B","end:B"],"result":["a","b"]}concurrent before: {"returned":false,"events":["start:A","start:B"]}concurrent after: {"events":["start:A","start:B","end:A","end:B"],"result":["a","b"]}The output renders the forEach result as null for visibility; its assertion checks the actual value is undefined.
While the gate is closed, forEach has already returned and both operations have started. The serial loop has started only A and has not returned. The concurrent batch has started both operations but has not returned. After release, the serial trace puts end:A before start:B; the concurrent trace shows both starts first.
This checks scheduling and completion ownership for two successful tasks. It is not a network benchmark, a throughput comparison, or a test of database ordering. The shared gate makes both concurrent completions happen in input order here; real operations may finish in another order.
Pick the start policy your work requires
| Requirement | Pattern | Verified boundary |
|---|---|---|
| B must start after A finishes | for...of with await work(item) | The fixture never starts B while A is held |
| A and B may be pending together | await Promise.all(items.map(item => work(item))) | Both start before gate release; the successful batch waits for both |
| Only synchronous side effects are needed | items.forEach(item => update(item)) | Suitable when there is no returned task promise to await |
With map, the callback’s returned values form the new array. Return the operation’s promise. A block callback such as items.map(item => { work(item); }) returns undefined for every item, so awaiting Promise.all over that array still loses the work. Use item => work(item) or item => { return work(item); }.
Promise.all preserves input order in its result array, even if operations finish out of order. That does not serialize writes. Mapping starts the calls without a concurrency cap; a large batch against a rate-limited service needs a separate bounded worker policy.
The examples assume an ordinary dense array that is not modified during processing. forEach skips empty slots; array iteration with for...of yields undefined for those slots. Replacing the loop on a sparse array can therefore change which inputs reach your operation.
Make failures belong to the awaited batch
An awaited serial operation that throws stops the loop before later operations start, unless your code catches the error and continues. With Promise.all, rejection makes the aggregate reject, but the other mapped operations have already been invoked. It does not cancel them or wait for their settlement before rejecting. If those tasks share a resource, follow the wait-before-cleanup pattern.
Supplemental Node.js assertions verified serial failure stopping before C, mapped async failure still invoking C, missing callback returns, sparse-array visitation, and a callback rejection bypassing the enclosing forEach catch. The rejection check attached a handler directly to the task to avoid an unhandled rejection. No browser, HTTP endpoint, database, cancellation API, or rate limiter was exercised.
Keep the batch’s completion promise in the caller’s control. Then choose separately whether the next operation may start before the previous one finishes.
References: ECMAScript: Array.prototype.forEach, ECMAScript: PerformPromiseAll, MDN: forEach, MDN: map, MDN: for…of, MDN: await, MDN: Promise.all. Checked October 11, 2026; fixture executed on Node.js 24.13.0.