Async forEach in JavaScript: Loops That Wait

- Why does async forEach finish before the work?
- What exactly is wrong with the callback?
- When should operations run sequentially?
- When is Promise.all appropriate?
- How do you preserve both successes and failures?
- How can you avoid launching everything at once?
- How do you run the helpers together?
- What should a regression test prove?
- Sources
Why does async forEach finish before the work?
JavaScript's forEach() does not wait for promises returned by an async callback. Use for...of with await when operations must run one after another. Use Promise.all() with map() for independent operations whose results you need together, or Promise.allSettled() when you need every success and failure. Decide the sequencing and failure policy before changing the loop.
This tutorial uses dense arrays: every position contains an item, and the input stays unchanged while the operation runs. The examples use local mock functions, not network requests. The code and failure cases were checked with Node.js v22.18.0; that is the verification environment, not a claim about the minimum supported Node version.
What exactly is wrong with the callback?
Run this self-contained example in a JavaScript file:
async function loadLabel(id) {
await Promise.resolve();
return "item-" + id;
}
const labels = [];
[1, 2, 3].forEach(async (id) => {
labels.push(await loadLabel(id));
});
console.log(labels.length); // 0
The immediate log runs before any callback reaches labels.push(). The mock deliberately includes an asynchronous suspension so the missing wait is observable without timers or a remote service.
There are two separate return values to consider. Each async callback returns a promise. The outer forEach() call returns undefined, discarding callback results. MDN documents this distinction in its forEach reference.
Consequently, await items.forEach(async (...) => ...) does not join those callback promises. It awaits the outer return value. Whether a particular small example appears finished afterward depends on its scheduling; apparent success does not establish a reliable completion contract.
An async function also turns an uncaught exception into a rejected promise. A try...catch around the original synchronous forEach() call does not collect those rejected callback promises. Replacing the loop must address errors as well as timing.
When should operations run sequentially?
Use this helper when the next operation must not start until the previous one succeeds:
async function loadSequential(ids, load) {
const values = [];
for (const id of ids) {
values.push(await load(id));
}
return values;
}
Here load is a function taking one ID and returning its result or a promise for that result. It must represent the complete operation, not launch detached work and return early.
For [1, 2, 3], the helper calls load(1), waits, then calls load(2), and finally load(3). If the second call rejects, the third is never called. Earlier successful side effects are not reversed; the helper itself simply rejects instead of returning its local result array.
This is useful for an ordered workflow, such as processing records where each step updates shared state needed by the next. It is also the simplest implementation when overlapping work is explicitly forbidden.
The placement of await is the mechanism: it suspends this async function until the awaited value settles. It does not freeze every other task in the application.
Keep the operation call inside the loop. Creating all the promises first and then awaiting them individually does not prevent their underlying work from having started already. Sequence the calls, not merely the reading of their results.
When is Promise.all appropriate?
For independent work, collect the operation promises and await the collection:
async function loadConcurrent(ids, load) {
return Promise.all(ids.map(async (id) => load(id)));
}
The async mapper returns each operation's result. Its wrapper also converts a synchronous exception from load into a rejected mapper promise. That keeps an operation-level failure in the same promise-based error path.
Promise.all produces fulfillment values in input order, even when completion order differs. One rejection rejects the aggregate promise; it does not cancel other operations.
In this helper, map() invokes the operation for every input without awaiting previous results. Promise.all() aggregates the resulting promises; it is not a task scheduler that decides when to start them.
Do not accidentally remove the mapper's return when adding braces. The expression async (id) => load(id) returns the operation. A block body needs return load(id) or an awaited result that it returns. Merely calling load(id) inside that block can detach the actual work from the promise being collected.
Choose this helper when the application treats the collection as unsuccessful if any item fails. If partial results have value, use the next helper instead. Neither choice implies that a failed collection is safe to retry wholesale when operations have side effects.
How do you preserve both successes and failures?
Use a settled-outcome report when every item needs an explicit result:
async function loadOutcomes(ids, load) {
const results = await Promise.allSettled(
ids.map(async (id) => load(id))
);
return results.map((result, index) => ({
id: ids[index],
...result
}));
}
For these dense, unchanged input arrays, each output retains its original ID. The result has either status: "fulfilled" and value, or status: "rejected" and reason. The allSettled reference defines those fields and the input-order guarantee.
A rejected item is now report data, not a thrown aggregate rejection. The caller must inspect statuses. Counting the report's length alone would incorrectly count failed items as successful.
For an import preview, an application might display successful rows and separately mark rejected rows for review. That is an application policy, not a rule supplied by JavaScript. Keep an internal error reason separate from a user-facing message: do not indiscriminately display raw service responses or confidential details.
Avoid making catch(() => undefined) your default recovery policy. It can erase the distinction between an operation that legitimately produced no value and one that failed.
How can you avoid launching everything at once?
For a small teaching example, fixed-size batches make the launch boundary explicit:
async function loadBatches(ids, load, batchSize = 2) {
if (!Number.isSafeInteger(batchSize) || batchSize < 1) {
throw new RangeError("batchSize must be a positive safe integer");
}
const values = [];
for (let start = 0; start < ids.length; start += batchSize) {
const batch = ids.slice(start, start + batchSize);
const loaded = await Promise.all(
batch.map(async (id) => load(id))
);
values.push(...loaded);
}
return values;
}
With five IDs and a batch size of two, calls start in groups of two, two and one. A new group starts only after the preceding group fulfills. If one item rejects, no later batch starts, but another operation in that failing batch can still be running.
The default of two is an illustration, not a recommended limit for a particular API. Read the service's current concurrency and rate-limit rules before selecting a production policy.
A batch boundary is not a requests-per-second limit. This helper also waits for the slowest successful item in a batch before starting another batch; it is not a continuously replenished worker pool. Prefer this simple form when its scheduling behavior matches the requirement.
How do you run the helpers together?
Place the helper definitions above in one file, followed by this demonstration. It performs no external writes or requests:
async function demo() {
const ids = [1, 2, 3];
console.log(await loadSequential(ids, loadLabel));
console.log(await loadConcurrent(ids, loadLabel));
console.log(await loadBatches(ids, loadLabel, 2));
const report = await loadOutcomes(ids, async (id) => {
if (id === 2) throw new Error("Example rejection");
return loadLabel(id);
});
console.log(report.map(({ id, status }) => [id, status]));
}
demo().catch(console.error);
The first three logs each contain ["item-1", "item-2", "item-3"]. The final log pairs ID 1 with "fulfilled", ID 2 with "rejected", and ID 3 with "fulfilled". If you included the initial broken example, its separate 0 log appears before these demonstration results.
The final catch is a demonstration error boundary. In an application, return or await the helper from the caller that owns the operation, and implement that caller's reporting policy. Do not declare completion before its promise settles.
What should a regression test prove?
Test behavior, not elapsed milliseconds. The following cases were checked locally alongside the examples:
| Test input or condition | Required observation |
|---|---|
| Empty array | Each helper returns an empty array |
| Second sequential operation rejects | Only IDs 1 and 2 were called |
| Concurrent operations resolve in reverse order | Returned values remain in input order |
| One concurrent operation rejects | Another already-started operation can still finish |
| Mixed settled outcomes | IDs and statuses remain paired in input order |
| Batch size zero or fractional | The batch helper rejects before calling the operation |
For ordering tests, use promises whose resolve functions the test controls. Resolve ID 3 first, then ID 2, then ID 1. This creates a deterministic completion sequence without assuming that a timer or network request meets a deadline.
If the actual operation uses Fetch, handle HTTP response validation and cancellation separately; see canceling Fetch with AbortController. An aggregate promise is not an abort mechanism. For nullable input properties, optional chaining explains access syntax, but required input validation still belongs before the work starts.
Select the helper by three questions: must calls be sequential, must every outcome be retained, and how much work may be in flight? Those answers define the loop more reliably than replacing one keyword.