async forEach JavaScript is one of those patterns that looks obviously correct and quietly does the wrong thing. It passes reviews, ships to production, and then causes failures that are hard to trace back to the source. This article explains what’s really happening, why your mental model is lying to you, and how this mistake sneaks into otherwise solid codebases.
Intro
This code looks fine.
It compiles.
It runs.
It even logs the numbers you expect.
And yet, it is doing the wrong thing.
Quietly.
The Snippet
async function run() {
[1, 2, 3].forEach(async n => {
await Promise.resolve();
console.log(n);
});
}
run();
Most developers look at this and assume one thing:
run()finishes after all the async work finishes.
That assumption is the bug.
The Obvious Mental Model (The Wrong One)
Here’s the story our brains tell us:
forEachloops over the array- the callback is
async awaitpauses execution- so
run()must wait for all of it
That story feels reasonable.
It’s also false.
JavaScript does not work that way.
What Actually Happens
Let’s strip this down to behavior.
forEachis synchronous- it does not know what a promise is
- it does not collect return values
- it does not wait for anything
When you pass an async function to forEach, this is what really happens:
forEachcalls the callback three times, synchronously- each call returns a promise immediately
forEachignores those promisesrun()reaches the end and resolves
The await inside the callback only pauses that callback.
It does not pause forEach.
It does not pause run().
It does not pause the caller of run().
You started async work.
You did not wait for it.
When run() Actually Finishes
This is the part that surprises people.
run() finishes before any console.log executes.
By the time the first promise resolves, the run() function is already done.
From the outside, it looks like this:
run()is complete- the system moves on
- background work is still happening
Nothing about the function signature tells you this.
That’s why this bug survives code review.
Why This Hurts Later
In real systems, this pattern causes damage in subtle ways.
- jobs are marked complete before they are
- transactions close too early
- metrics lie
- cleanup runs while work is still in flight
- errors become unhandled rejections
This code doesn’t fail loudly.
It fails politely.
And polite failures are the worst kind.
The Core Mistake
The mistake isn’t “using forEach wrong”.
The mistake is misunderstanding control flow.
A few hard truths:
asyncdoes not make callers waitawaitonly affects the current function- nothing in JavaScript is implicitly awaited
- if you don’t explicitly own the promises, you don’t control execution
If nothing is awaiting it, it’s fire-and-forget.
Whether you meant it or not.
The Mental Model You Actually Need
Think in terms of ownership.
- Who starts the async work?
- Who is responsible for knowing when it finishes?
- Who observes failure?
If the answer is “no one”, you have a bug.
This isn’t about syntax.
It’s about making completion and failure explicit.
What Correct Code Looks Like (Conceptually)
Not specific APIs.
Not recipes.
Just principles:
- use constructs that return promises
- collect async work intentionally
- make waiting visible
- make failure observable
If the function claims to do work, it should not return until that work is done.
Anything else is lying.
Why This Question Exists
This question isn’t here to trick you.
It exists because:
- many experienced developers get it wrong
- the code looks innocent
- the failure mode is delayed
- the cost shows up later, in production
If you’ve been burned by this once, you never forget it.
And if you haven’t yet, now you know where the fire is.