DevLift
Back to Blog

async forEach: The `await` That Does Nothing

`forEach` doesn't return a Promise, so `await` before it resolves instantly. Every async callback fires and is forgotten — this is how batch processors ship broken.

Admin
July 31, 20265 min read4 views

async forEach: The await That Does Nothing

Picture this: a batch invoice processor ships. It loops through unpaid invoices, marks each one paid, and sends a confirmation email. The function awaits the forEach. The developer tests it locally with three invoices — everything works. The PR passes review.

In production, with 200 invoices queuing up overnight, the API returns 200 OK — "all invoices processed" — and fires off confirmation emails. Then support tickets start arriving: most invoices are still PENDING. The processing happened, but it finished 15 seconds after the response was sent, running loose in the background, uncaptured, out of order.

This is one of the most surprisingly common bugs in modern JavaScript codebases. async/await made async code look synchronous, which is great — until developers apply that mental model to Array.prototype.forEach, which predates promises and was never designed to participate in them.

The Mistake

The code looks so reasonable:

// src/services/invoiceService.ts
async function processInvoices(invoiceIds: string[]) {
  await invoiceIds.forEach(async (id) => {
    const invoice = await prisma.invoice.findUnique({ where: { id } });
    await stripe.paymentIntents.confirm(invoice.paymentIntentId);
    await prisma.invoice.update({
      where: { id },
      data: { status: "PAID", paidAt: new Date() },
    });
  });
 
  await sendBatchConfirmation(invoiceIds); // "all done" — runs before invoices are updated
}

await before forEach: looks sequential. async (id) => { ... }: looks like it'll wait. await before each operation inside: looks correct.

None of it matters. forEach returns undefined. await undefined resolves immediately. Every async callback fires in parallel, untracked, and the outer function races straight to sendBatchConfirmation.

Why It Happens

Array.prototype.forEach was introduced in ES5, years before Promises existed. Its internal implementation roughly looks like this:

Array.prototype.forEach = function (callback) {
  for (let i = 0; i < this.length; i++) {
    callback(this[i], i, this); // return value is DISCARDED
  }
  // returns undefined
};

It calls the callback synchronously, discards whatever the callback returns, and returns undefined. When you pass an async function as the callback, that function does return a Promise — but forEach throws that Promise away. Nobody owns it. Nobody awaits it. Nobody catches its errors.

await invoiceIds.forEach(...) is await undefined, which resolves on the next microtask tick. Your forEach has already launched all the async callbacks by then — they're just running loose in the background.

This is also why errors go somewhere you weren't looking:

invoiceIds.forEach(async (id) => {
  throw new Error("payment failed"); // nobody is listening for this rejection
});
console.log("this line still runs");  // ...and so does everything after it

The promise carrying that rejection has no .catch() handler and no parent await to propagate it, so it never reaches your caller, your try/catch, or your error middleware. On Node 14 and earlier that produced an UnhandledPromiseRejectionWarning and execution continued. Since Node 15 the default is --unhandled-rejections=throw, which kills the process — I get exit code 1 on Node 22. Either way the outcome is bad for the same reason: the synchronous code after forEach has already run, so your 200 OK is out the door before the process notices anything went wrong.

Real-World Examples

Example 1: Batch payment processing with silent failures

// Before: payments fire in parallel, caller doesn't know they failed
async function chargeAllSubscribers(userIds: string[]) {
  await userIds.forEach(async (userId) => {
    const user = await getUser(userId);
    await stripe.charge(user.stripeCustomerId, user.subscriptionPrice);
    await db.subscription.update({ where: { userId }, data: { status: "ACTIVE" } });
  });
 
  console.log("All subscriptions renewed"); // logs before charges complete
  return { renewed: userIds.length };       // returns wrong count
}

If stripe.charge throws for any user, the error never reaches this function. It returns { renewed: userIds.length } whether every charge succeeded or every charge failed — the count is of IDs you passed in, not of charges that landed. No retry, no rollback, no alerting.

// After: sequential processing, one failure recorded per user, none swallowed
async function chargeAllSubscribers(userIds: string[]) {
  const results: Array<{ userId: string; status: string; error?: string }> = [];
 
  for (const userId of userIds) {
    const user = await getUser(userId);
    try {
      await stripe.charge(user.stripeCustomerId, user.subscriptionPrice);
      await db.subscription.update({ where: { userId }, data: { status: "ACTIVE" } });
      results.push({ userId, status: "ok" });
    } catch (err) {
      results.push({ userId, status: "failed", error: (err as Error).message });
    }
  }
 
  const failed = results.filter(r => r.status === "failed");
  if (failed.length > 0) await alertOnCall(failed);
 
  return results;
}

for...of respects await. Each charge completes — or throws — before the next one starts. Errors are captured per-user and escalated rather than silently discarded.

Example 2: Data migration that reports success before records land

// Before: migration marked complete before all writes finish
async function migrateUserData(users: User[]) {
  await users.forEach(async (user) => {
    const transformed = transformUserSchema(user);
    await newDb.users.create({ data: transformed });
  });
 
  await markMigrationComplete("users"); // runs at tick 1, records write at tick N
}

markMigrationComplete runs almost instantly. The actual record writes continue in the background. If anything fails mid-migration, the run is stamped complete with partial data. Re-running it will fail on unique constraint violations for records that did land.

// After: Promise.all with map — parallel but awaited
async function migrateUserData(users: User[]) {
  await Promise.all(
    users.map(async (user) => {
      const transformed = transformUserSchema(user);
      await newDb.users.create({ data: transformed });
    })
  );
 
  await markMigrationComplete("users"); // only runs after ALL writes succeed
}

Promise.all with map is the right tool when operations are independent and can run concurrently. map returns an array of Promises; Promise.all awaits every one. If any rejects, Promise.all rejects — the error propagates properly to the caller.

Example 3: Email sender that always reports zero sent

// Before: emails fire and forget, sentCount is always 0
async function sendCampaign(recipients: string[], template: EmailTemplate) {
  let sentCount = 0;
 
  recipients.forEach(async (email) => {
    await mailer.send({ to: email, ...template });
    sentCount++; // outer function has already returned by the time this runs
  });
 
  return { sent: sentCount }; // always { sent: 0 }
}

Two bugs at once: sentCount is always 0 because the outer function returns before any callback completes, and even if it didn't, the increments would race against each other.

// After: Promise.allSettled for best-effort sending with per-email status
async function sendCampaign(recipients: string[], template: EmailTemplate) {
  const results = await Promise.allSettled(
    recipients.map((email) => mailer.send({ to: email, ...template }))
  );
 
  const sent = results.filter(r => r.status === "fulfilled").length;
  const failed = results.filter(r => r.status === "rejected").length;
 
  return { sent, failed };
}

Promise.allSettled doesn't short-circuit on the first rejection — unlike Promise.all. Every recipient gets a send attempt; you get back a complete status record.

Example 4: Sequential migrations where order matters

// Before: SQL files may execute out of order
async function applyMigrationFiles(files: string[]) {
  await files.forEach(async (file) => {
    const sql = await fs.promises.readFile(file, "utf8");
    await db.query(sql); // file 3 might run before file 1 finishes
  });
}

When file 3 runs a DROP COLUMN before file 2 adds the column, the migration fails in a timing-dependent way — clean in a quiet test environment, broken under any real load.

// After: for...of guarantees strictly sequential execution
async function applyMigrationFiles(files: string[]) {
  for (const file of files) {
    const sql = await fs.promises.readFile(file, "utf8");
    await db.query(sql);
  }
}

The Fix: Three Patterns for Three Scenarios

Sequential — when order matters or operations interact:

for (const item of items) {
  await processItem(item);
}

Use this when each step must complete before the next starts: migrations, dependent writes, or anything that could conflict if run concurrently.

Parallel with all-or-nothing semantics:

await Promise.all(items.map(item => processItem(item)));

Use this when operations are independent and a single failure should abort everything. Fastest option. Rejects as soon as any promise rejects.

Parallel with per-item status (best-effort):

const results = await Promise.allSettled(
  items.map(item => processItem(item))
);
const failures = results.filter(r => r.status === "rejected");

Use this when partial success is acceptable and you need to know which items failed — sending emails, posting webhooks, updating third-party records.

Concurrency-limited parallel — when you can't saturate downstream:

import pLimit from "p-limit";
const limit = pLimit(5); // max 5 concurrent
 
await Promise.all(
  items.map(item => limit(() => processItem(item)))
);

When you have 10,000 items and can't fire 10,000 concurrent requests at an API without hitting rate limits, p-limit lets you parallelize while capping concurrency.

How to Catch This in Code Review

The signal is simple: any forEach with an async callback. It's almost always wrong.

// red flag — stop and investigate
items.forEach(async (item) => { ... });
await items.forEach(async (item) => { ... }); // both forms are wrong

The await before forEach is a false-security smell — it means the developer expected this to wait. That expectation is wrong. Dig into what they actually need:

  • Does the caller need to know when these operations complete? (Almost always yes — so for...of or Promise.all.)
  • What happens if one of these throws? (If the answer is unclear, it's swallowed.)
  • Does order matter? If yes, for...of. If no, Promise.all with map.
  • Is there a rate-limiting concern? If yes, p-limit.

The ESLint rule you actually want is no-misused-promises, not no-floating-promises:

{
  "rules": {
    "@typescript-eslint/no-misused-promises": "error",
    "@typescript-eslint/no-floating-promises": "error"
  }
}

This trips people up, so it's worth being precise. I ran both rules against items.forEach(async (item) => { await processItem(item); }):

  • no-misused-promises flags it — "Promise returned in function argument where a void return was expected." That is exactly the bug: forEach declares its callback as returning void, and you handed it something returning Promise<void>.
  • no-floating-promises does not flag it. It reports nothing on that line. The forEach call itself returns undefined, so as far as that rule is concerned there is no promise floating anywhere. It catches a bare processItem(1); and misses the forEach entirely.

If you enable only no-floating-promises — which is the one people reach for, and the one bundled in recommended-type-checked alongside it — you will believe you're covered and you are not. Both rules need type information, so they only run with parserOptions.project set.

One rule to not reach for: no-await-in-loop. It fires on await inside any loop body, which means it flags the for...of fix recommended above — the correct answer when order matters. If you turn it on, expect to disable it every time you write a deliberately sequential loop.

🚨
forEach discards every Promise its callback returns — async callbacks inside forEach fire and are forgotten. Use for...of for sequential work and Promise.all(items.map(...)) for parallel.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Open a modal 10 times without cleanup and you've got 10 stacked timers silently draining memory. Here's what this looks like in a PR and how senior devs catch it.
AdminJuly 31, 20266 min read
Picture this: a live dashboard that polls for new notifications every 3 seconds. The count increments in state, the UI updates, everything looks right — except the interval callback is silently reading count = 0 on every tick, because it…
AdminJuly 31, 20266 min read
Build a pub/sub system from 60 lines of JavaScript: subscribe, publish, unsubscribe, once(), namespace wildcards, error isolation, and async publish.
AdminJuly 31, 20266 min read