Why async + forEach Lies to You

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:

  • forEach loops over the array
  • the callback is async
  • await pauses 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.

  • forEach is 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:

  1. forEach calls the callback three times, synchronously
  2. each call returns a promise immediately
  3. forEach ignores those promises
  4. run() 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:

  • async does not make callers wait
  • await only 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.

Scroll to Top