Why forEach Does Not Wait for async Callbacks

In Brief
forEach() calls its callback and ignores the value returned from it, including a promise. Use for...of and await each task when order or dependency requires a sequence. Use Promise.all(items.map(...)) when tasks may start concurrently and every result is required. Concurrency isn't parallel threads, and Promise.all() rejects as soon as one input rejects.
It's an easy mistake to make. You have an array of items, each one needs asynchronous work, and forEach() already accepts a callback. Add async, put an await inside it, then await the loop itself. The code reads as though it should pause until every item has finished.
It doesn't.
Here is a small example using fixed delays. The second save rejects so that the failure is visible as well as the timing:
const records = [ { name: 'Ellie', delay: 30, shouldFail: false }, { name: 'Maddie', delay: 10, shouldFail: true }, { name: 'Sophie', delay: 20, shouldFail: false },];const saveRecord = ({ name, delay, shouldFail }) => new Promise((resolve, reject) => { setTimeout(() => { if (shouldFail) { reject(new Error(`Could not save ${name}`)); return; } resolve(`Saved ${name}`); }, delay); });const saveAll = async () => { try { await records.forEach(async (record) => { console.log(await saveRecord(record)); }); console.log('Finished saveAll'); } catch (error) { console.error(error.message); }};saveAll();The exact presentation of the rejected promise depends on the browser or runtime, but the important order is stable:
Finished saveAllUncaught Error: Could not save MaddieSaved SophieSaved EllieThe function reports that it has finished before any save has completed. More surprisingly, the catch around the forEach() doesn't catch the rejected save.
What forEach() Actually Does
forEach() is a synchronous array method. It visits the available elements in iteration order and calls the supplied callback for each one. It doesn't do anything with the callback's return value, and the method itself returns undefined. That behaviour is visible in the `Array.prototype.forEach()` algorithm in ECMAScript 2017, which calls the callback but does not retain its result.
There are three moments worth keeping separate: when a callback starts, when its asynchronous work settles, and when the function containing the loop completes. In the failing example, the callbacks all start during the synchronous forEach() call. Each reaches await and returns a pending promise. The containing function then reaches its completion message because it has no aggregate promise tying those later settlements back to the loop.
An async function always returns a promise. That means each callback above does produce a promise, but forEach() discards it. It neither collects those promises nor waits for them.
The outer await therefore receives undefined. Awaiting a non‑promise value is allowed, but it cannot somehow recover the promises that were already ignored. Execution continues, and Finished saveAll is logged.
Why async Does Not Change the Caller
Marking a callback async changes what that callback returns. It does not change the contract of the function calling it. A method has to be designed to observe returned promises before it can coordinate them.
That also explains the escaped rejection. The try block only awaits the value returned by forEach(). It has no link to the individual callback promises, so their later rejections sit outside that apparent error boundary.
When Each Task Must Finish in Sequence
Sometimes the next task depends on the previous one. Perhaps each request changes shared state, an endpoint imposes a strict rate limit, or processing must stop at the first failure. A for...of loop makes that policy plain:
const saveInSequence = async () => { try { for (const record of records) { console.log(await saveRecord(record)); } console.log('Finished saveInSequence'); } catch (error) { console.error(error.message); }};This starts Ellie's save and waits for it. Only then does it start Maddie's. When Maddie's save rejects, control moves to catch; Sophie's save is never started, and the final completion message isn't printed.
Sequence is useful when it expresses a real dependency. It is also slower when the tasks are independent, because time spent waiting for one task cannot overlap with another.
When Tasks May Start Together
If the records are independent and all successful results are required, create the promises explicitly with map() and join them with Promise.all():
const saveConcurrently = async () => { try { const results = await Promise.all( records.map((record) => saveRecord(record)) ); results.forEach((result) => console.log(result)); console.log('Finished saveConcurrently'); } catch (error) { console.error(error.message); }};All three saves are started during the map() call. Promise.all() returns one aggregate promise, so the surrounding await and catch now have something meaningful to observe.
Promise.all() doesn't start jobs by itself or move JavaScript onto extra threads. Calling saveRecord() during map() starts each operation, and Promise.all() observes the promises produced by those calls. That distinction matters when reading less compact code: if promises were created earlier, their work may already be under way before the aggregate is constructed.
Result Ordering and Failure
When every input fulfils, the resulting array keeps input order. Ellie's result remains first even though her timer completes last. Completion order and result order are separate concerns. The index captured by the standard's `Promise.all()` resolve‑element algorithm is what preserves that correspondence.
If any input rejects, the aggregate promise rejects with that reason as soon as it is observed. The other saves aren't cancelled; they may still complete, but this call no longer produces a successful results array. That fail‑fast policy is useful when every result is required. It is the wrong policy when partial success must be inspected deliberately.
Choose the Policy Before the Syntax
The choice isn't really between two fashionable loop styles. It is a choice about the work:
| Requirement | Use | Behaviour |
|---|---|---|
| Each task depends on the one before it | for...of with await | Starts one at a time and stops on rejection |
| Independent tasks must all succeed | Promise.all(records.map(...)) | Starts tasks concurrently and joins their promises |
| Fire‑and‑forget work is genuinely intended | An explicit background strategy | Handle and report each failure deliberately |
Starting hundreds of independent tasks together may also put unreasonable pressure on a service or the browser. Neither example is a rate limiter. If the number of records is unbounded, concurrency needs another policy rather than a larger Promise.all().
Debugging the Original Pattern
Put a log immediately before the loop, at the start and end of each callback, and after the awaited expression. If the after‑loop message appears before the callback completions, inspect what the collection method returns. Also make every rejection observable during development. A promise that outlives its caller is much easier to diagnose when it cannot fail silently.
Wrapping Up
forEach() is doing exactly what its contract says. The misleading part is that an async callback looks like it ought to make the caller async‑aware.
Use for...of when one awaited step should follow another. Build an array of promises and await Promise.all() when independent work may overlap and belongs to one all‑or‑nothing operation. In both cases, completion and rejection become part of the control flow instead of accidental background behaviour.
Postscript
Aug 2026: This article forms part of an archive restored from a previous version of my website. Its original publication date is accurate. During the restoration, I reviewed and updated it where appropriate for formatting, imagery, broken links, code correctness, and current internal references, whilst preserving the original technical context and intent.