Why forEach Does Not Wait for async Callbacks

Hero image for Why forEach Does Not Wait for async Callbacks. Image by Tasha Kostyuk.
Hero image for 'Why forEach Does Not Wait for async Callbacks.' Image by Tasha Kostyuk.

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 Ellie

The 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 nonpromise 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()` resolveelement 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 failfast 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:

RequirementUseBehaviour
Each task depends on the one before itfor...of with awaitStarts one at a time and stops on rejection
Independent tasks must all succeedPromise.all(records.map(...))Starts tasks concurrently and joins their promises
Fireandforget work is genuinely intendedAn explicit background strategyHandle 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 afterloop 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 asyncaware.

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 allornothing 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.


Need a senior engineer involved?

I can work directly in the codebase, review the architecture, or support the team through delivery when the work needs more than extra hands.