Mesrai
Back to blog
// essayTechnical Deep Dive

Async/Await Inside forEach: Why It Skips Errors + How Mesrai Flags

Real PR catch: `items.forEach(async ...)` — outer code does not await, errors disappear. for..of and Promise.all fixes.

Mesrai TeamJuly 28, 20268 min read

A worker loop calling `forEach` with an async callback. Looks like every async-iteration tutorial. Behaves like neither: `forEach` does not await, the outer function returns before any work is done, and errors disappear.

The vulnerable diff

tsworkers/process-batch.ts
// workers/process-batch.ts
export async function processBatch(items: Item[]) {
  // BUG: forEach does not await async callbacks
  items.forEach(async (item) => {
    await processItem(item); // promise discarded by forEach
  });

  await markBatchComplete(); // runs BEFORE any processItem finishes
  return { count: items.length };
}

What is wrong

`Array.prototype.forEach` does not return anything and does not await its callback. The callback returns a Promise, the Promise is discarded, `forEach` exits immediately. The outer function returns `count` before any item has been processed. Errors thrown inside the async callback become unhandled promise rejections, which on modern Node crash the process. Mesrai catches the pattern on every PR — it is one of the most common async bugs and ESLint's `no-misleading-character-class` does not cover it.

The attack

Symptom in production:

textexploit
1. processBatch returns. Caller assumes items processed.
2. markBatchComplete writes to DB: batch marked done.
3. Some processItem calls finish seconds later. Some fail with
   `UnhandledPromiseRejection` — process exits.
4. State in DB now: 'batch complete' + half the items unprocessed.

No exception in the calling code. No log of the failure. Just a
corrupted batch state that humans find days later.

Recovery is manual — replay the missed items, sometimes hours of investigation to find which.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Logic] [Async-forEach] [high]

`Array.prototype.forEach` does not await its callback. The async
callback returns a Promise which forEach discards; outer code
proceeds before any iteration finishes. Errors become unhandled
rejections (crash on modern Node).

Two correct patterns. Pick by whether iterations must be sequential:

  // Sequential (each item waits for previous to finish, errors throw):
  for (const item of items) {
    await processItem(item);
  }

  // Parallel (all start at once, all errors aggregated):
  await Promise.all(items.map(item => processItem(item)));

If you need parallel with bounded concurrency:
  import pLimit from "p-limit";
  const limit = pLimit(8);
  await Promise.all(items.map(item => limit(() => processItem(item))));

The fix

tsworkers/process-batch.ts (fixed)
// workers/process-batch.ts — fixed (parallel)
export async function processBatch(items: Item[]) {
  await Promise.all(items.map(item => processItem(item)));
  await markBatchComplete();
  return { count: items.length };
}

// Fixed (sequential, if order matters or you need backpressure)
export async function processBatch(items: Item[]) {
  for (const item of items) {
    await processItem(item);
  }
  await markBatchComplete();
  return { count: items.length };
}

Two correct shapes. `Promise.all + map` for parallel work where order does not matter. `for..of + await` for sequential work where each iteration depends on the previous one. Both await all iterations before the outer function returns; both propagate errors.

Why human review missed it

`forEach` with an async callback looks like every other iteration pattern. The bug is invisible at code review because the syntax is right, the types are right, and the tests often pass — Jest's `await processBatch(items)` returns before the inner work fails, the test asserts 'no error', the test passes. Mesrai catches the pattern on every PR by flagging async callbacks passed to `.forEach`, `.map` without `await`, and `setTimeout`.

Related rules + further reading

Mesrai rule pack: logic/no-async-in-foreach — flags async callbacks passed to .forEach (and similar).

ESLint plugin: eslint-plugin-promise has no-promise-in-callback for this class.

Top-3 most common async bug across the projects we audit.

Takeaway

`forEach` does not await. Use `for..of` for sequential or `Promise.all` for parallel. Both are one line. Mesrai catches the bad pattern on every PR.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.