· 6 min read
Why await inside forEach doesn't wait, and what to use instead
Using await inside forEach doesn't wait: forEach ignores the promises, so code runs early and errors escape. Here's why, plus for...of and Promise.all fixes.

await inside forEach does not wait. Array.prototype.forEach calls your callback for each item and throws away whatever it returns, so the promises your async callback produces are never awaited, and the code after the loop runs before any of the work finishes. Use for...of when you need the items handled one at a time, or Promise.all with map when they can run in parallel.
This is one of the most common async bugs in JavaScript, and it is easy to miss because the code reads correctly and often passes a quick manual test.
Reproduce it
Here is the version that looks fine:
async function saveAll(ids) {
ids.forEach(async (id) => {
await save(id);
});
console.log('All saved');
}
Run it with a save function that takes a few hundred milliseconds, and the output comes out in the wrong order:
All saved
saved 1
saved 2
saved 3
"All saved" prints first. If the caller does await saveAll(ids) and then redirects the page, closes a database connection or ends a serverless function, some of the saves never happen. In a short script, Node waits for pending timers and sockets before exiting, so the work completes anyway and the bug stays hidden. In a request handler or a test, it does not.
Why doesn't await work inside forEach?
An async function always returns a promise. The await inside it pauses that one callback, not the function that called forEach.
forEach was designed before promises existed. It calls the callback, ignores the return value and moves on to the next item, then returns undefined when it reaches the end. There is no promise for the outer function to await, so nothing connects the loop to the work it started. Each callback runs on its own schedule.
You can see it directly:
const result = [1, 2, 3].forEach(async (x) => x);
console.log(result); // undefined
There is also an error-handling side effect that is worse than the timing problem. A try...catch around the loop cannot catch errors thrown inside the callbacks, because by the time they reject, the try block has already finished:
try {
[1, 2].forEach(async (id) => {
throw new Error('boom ' + id);
});
} catch (err) {
console.log('caught', err.message); // never runs
}
In Node 22 this prints nothing from the catch, then crashes the process with an unhandled promise rejection. Since Node 15, an unhandled rejection is treated as a fatal error by default. An unhandled rejection is a promise that fails without anything attached to handle the failure. In a browser, you get a red console error and the rest of the page keeps going, which can be harder to notice.
How do you await each item in a loop?
Pick the fix based on whether the order and the number of requests in flight matter.
One at a time: for...of
A plain for...of loop runs inside the outer async function, so await pauses the whole loop:
for (const id of ids) {
await save(id);
}
console.log('All saved');
Items finish in order, and a thrown error stops the loop and reaches the surrounding try...catch. The cost is time: three saves of 300, 100 and 200 ms take about 600 ms in total. Use this when each step depends on the previous one, when order matters, or when the receiving system cannot handle parallel writes.
All at once: Promise.all with map
map keeps the return values, so an async callback gives you an array of promises. Promise.all turns that array into one promise you can await:
const results = await Promise.all(ids.map((id) => save(id)));
The same three saves now take about 300 ms, the length of the slowest one. results keeps the order of ids, not the order in which they finished. If any promise rejects, Promise.all rejects with the first error, but the other operations keep running in the background because promises cannot be cancelled. When you want every outcome, including failures, use Promise.allSettled, which resolves with an array of { status, value } or { status, reason } objects.
A fixed number at a time: a small worker pool
Firing 5,000 requests at once with Promise.all is a good way to hit an API's limits or exhaust a connection pool. A few lines give you bounded concurrency, meaning at most N operations in flight at any moment:
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i], i);
}
}
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
await Promise.all(workers);
return results;
}
const results = await mapLimit(ids, 3, save);
Each worker picks the next index, waits for it, then picks another. Because JavaScript runs this code on a single thread, next++ cannot be claimed twice. With 10 items, a limit of 3 and 100 ms per item, it finishes in about 400 ms and never has more than three running. If the API you are calling enforces quotas, this pairs well with the ideas in rate limiting with a token bucket or sliding window.
Which array methods have the same problem?
Any array method that expects a plain value back from its callback breaks the same way with an async function:
| Method | What happens with an async callback | Fix |
|---|---|---|
forEach | Promises ignored, returns undefined | for...of or Promise.all(map) |
map | Returns an array of promises | Wrap in Promise.all |
filter | Every promise is truthy, so nothing is removed | Resolve the predicates first |
some, every, find | Treat every promise as truthy | Resolve first, or use a loop |
reduce | Works only if each step awaits the previous accumulator | Usually clearer as for...of |
The filter case is especially sneaky because it fails silently. A promise object is truthy whatever it eventually resolves to, so ids.filter(async (id) => id > 2) returns the whole array. Resolve the predicates first, then filter by index:
const keep = await Promise.all(ids.map((id) => isActive(id)));
const active = ids.filter((_, i) => keep[i]);
Array.fromAsync is another option when you already have an async iterable or want to map with an async function and collect the results. It awaits each value in turn, so it behaves like for...of, not like Promise.all. It is available in current browsers and Node 22.
Catch it before it ships
You do not have to rely on code review. The typescript-eslint rule @typescript-eslint/no-misused-promises reports an async function passed where a callback is expected to return nothing, which is exactly the forEach case. @typescript-eslint/no-floating-promises catches the related mistake of calling an async function without awaiting or handling it. Both need type information, so they run with the type-aware lint configuration.
If an awaited request inside your loop can fail with an HTTP error, remember that fetch() does not reject on 404 or 500, so a loop can "succeed" while every request failed.
Key takeaways
forEachignores the promise your async callback returns and returnsundefined, so there is nothing to await.- Errors thrown inside an async
forEachcallback skip yourtry...catchand become unhandled rejections, which crash Node by default. - Use
for...offor sequential work andPromise.all(items.map(fn))for parallel work. - Limit concurrency with a small worker pool when you would otherwise start hundreds of requests at once.
filter,some,everyandfindalso misbehave with async callbacks, because a promise is always truthy.
FAQ
Is forEach with async ever fine?
Only for true fire-and-forget work where you do not care when it finishes or whether it fails, and even then you should attach a .catch to each call. In most code, a for...of loop or Promise.all states the intent more clearly.
Does for await...of fix this?
for await...of is for async iterables, such as streams or async generators. It also works on an array of promises, awaiting them in order, but for a plain array of IDs a normal for...of with await inside is simpler and does the same job.
Is Promise.all faster than for...of?
For independent operations, usually yes, because the waits overlap and the total time is close to the slowest single operation. It also puts more load on the service you are calling at once, which is why bounded concurrency is often the better middle ground.