Best Practices for try/catch in Async Loops

Async loops are the most common place rejections escape silently — a misplaced try/catch, a forgotten await, or an Array.prototype.forEach that ignores returned Promises turns a single network hiccup into an UnhandledPromiseRejection crash. This page is a companion to Handling Unhandled Promise Rejections in Modern JS and sits inside Core JavaScript Error Handling & Boundaries.

Async loop error-boundary comparison Three columns showing forEach with async callback (rejection escapes to global unhandledRejection), for-of with per-iteration try/catch (rejection is contained, loop continues), and Promise.allSettled (all promises run to completion, rejected and fulfilled results separated). forEach + async for-of + try/catch Promise.allSettled items.forEach( async callback await fetchResource() Promise returned → ignored Rejection escapes → unhandledRejection BROKEN for (const item of items) try { await fetch() } catch (err) { log(err) } Rejection contained here Loop continues next iteration runs CONTAINED Promise.allSettled( items.map(fetch) results array {status, value|reason} fulfilled → use value rejected → log reason ALL CAPTURED Each pattern determines whether a rejection is contained locally or escapes to the global unhandledRejection handler

Symptom / Trigger

The first sign is usually one of these messages in the Node.js process output or the browser console:

// Node.js (v15+)
node:internal/process/promises:288
            triggerUncaughtException(err, true /* fromPromise */);
            ^

[UnhandledPromiseRejection: This error originated either by throwing inside of an async
function without a catch block, or by rejecting a promise which was not handled with .catch().
The promise rejected with the reason "NetworkError: fetch failed".]
  at processTicksAndRejections (node:internal/process/promises:96:5) {
    code: 'ERR_UNHANDLED_REJECTION'
  }

// Node.js (v12–14) — warning instead of crash
UnhandledPromiseRejectionWarning: NetworkError: fetch failed
    at fetchResource (/app/src/api.js:14:11)
    at /app/src/processor.js:8:5
    at Array.forEach (<anonymous>)
    at processItems (/app/src/processor.js:6:10)
UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either
by throwing inside of an async function without a catch block, or by rejecting a promise
which was not handled with .catch(). Use the CLI flag `--unhandled-rejections=strict` to
treat unhandled promise rejections as errors (effective in Node.js v15+).

// Browser (Chrome DevTools console)
Uncaught (in promise) NetworkError: fetch failed
    at fetchResource (api.js:14:11)
    at processor.js:8:5

The key detail in all three variants is the stack pointing into Array.forEach or an anonymous callback rather than a named catch site. That is the fingerprint of a loop that discards returned Promises.

Root Cause Explanation

Three common patterns each leak rejections for a different structural reason.

Pattern 1 — forEach with an async callback. Array.prototype.forEach is a synchronous method that calls your callback, receives the Promise it returns, and then immediately discards it. The rejection has nowhere to go except the global unhandledrejection handler.

// BROKEN: forEach discards every Promise the async callback returns
items.forEach(async (item) => {
  await fetchResource(item.id); // if this rejects, nobody catches it
});
// forEach returns undefined — the caller cannot await anything

Pattern 2 — single outer try/catch around Promise.all. When one promise rejects, Promise.all rejects immediately with that reason and the catch block receives only the first error. All other in-flight promises are still running but their results — successes and failures alike — are discarded.

// BROKEN: one rejection loses all fulfilled results
try {
  const results = await Promise.all(items.map(item => fetchResource(item.id)));
} catch (err) {
  // err is the FIRST rejection only; all other results are gone
  console.error(err);
}

Pattern 3 — for...of with a missing await. A for...of loop does not automatically await anything. Forgetting await means you assign a Promise to a variable but never suspend execution on it. The rejection fires asynchronously — after the loop has already moved on to the next iteration or returned entirely.

// BROKEN: the Promise is stored in `result` but never awaited or caught
for (const item of items) {
  const result = fetchResource(item.id); // missing await!
  // loop continues immediately; rejection escapes
}

Step-by-Step Fix

1. Replace forEach with for...of and add per-iteration try/catch

Use for...of inside an async function so that await works correctly and rejections are trapped at the iteration boundary. The loop continues even when a single item fails.

const processItems = async (items) => {
  for (const item of items) {
    try {
      const result = await fetchResource(item.id); // await suspends correctly
      console.log('ok', item.id, result);
    } catch (err) {
      // rejection is isolated to this iteration; the loop does not stop
      console.error('failed', item.id, err.message);
    }
  }
};

Every iteration is an independent error boundary. A rejection in iteration 3 does not prevent iterations 4 through N from running. The enclosing async function completes normally and returns a resolved Promise — no rejection ever reaches the caller or the global handler.

2. Use for await...of for async iterables with per-iteration try/catch

When your data source is an async generator or a readable stream, use for await...of. The try/catch must wrap the entire for await expression, but you can place an inner try/catch for per-item isolation.

async function* paginate(cursor) {
  while (cursor) {
    const page = await fetchPage(cursor); // generator can throw here
    cursor = page.nextCursor;
    for (const item of page.items) yield item;
  }
}

const consumePages = async () => {
  try {
    // outer try/catch catches errors thrown by the generator itself
    for await (const item of paginate(startCursor)) {
      try {
        await processItem(item); // per-item boundary
      } catch (err) {
        console.error('item processing failed', item.id, err.message);
        // loop continues to the next yielded item
      }
    }
  } catch (genErr) {
    // generator threw — the async iterable itself is broken
    console.error('paginator failed', genErr.message);
  }
};

The outer catch handles generator-level errors (network failure fetching the next page). The inner catch handles per-item processing errors. Both layers are necessary.

3. Switch Promise.all to Promise.allSettled for concurrent batch work

When you want to fire all requests concurrently and collect every outcome — whether fulfilled or rejected — use Promise.allSettled. It never short-circuits: all promises run to completion before the await resolves.

const processBatch = async (items) => {
  const outcomes = await Promise.allSettled(
    items.map(item => fetchResource(item.id)) // all start concurrently
  );

  const failures = outcomes.filter(o => o.status === 'rejected');
  const successes = outcomes.filter(o => o.status === 'fulfilled');

  // log each failure with its index for correlation
  failures.forEach((f, i) => {
    console.error(`item[${i}] rejected:`, f.reason.message);
  });

  return successes.map(s => s.value); // only the resolved values
};

Each element in the outcomes array is { status: 'fulfilled', value } or { status: 'rejected', reason }. No rejection escapes to the global handler.

4. Keep Promise.all for atomic operations but wrap it and handle partial rollback

If the operation is truly atomic — every item must succeed or the whole batch should be rolled back — Promise.all is the right primitive. Wrap the entire call in try/catch and execute compensating logic in the catch block.

const saveAllOrRollback = async (records) => {
  let insertedIds = [];

  try {
    insertedIds = await Promise.all(
      records.map(record => db.insert(record)) // atomic: all or nothing
    );
    return { ok: true, ids: insertedIds };
  } catch (err) {
    // Promise.all rejected — first failure reason is in err
    console.error('batch insert failed, rolling back:', err.message);
    // roll back the inserts that did succeed before the short-circuit
    await Promise.allSettled(insertedIds.map(id => db.delete(id)));
    return { ok: false, reason: err.message };
  }
};

The rollback itself uses Promise.allSettled so that compensating deletes do not cascade into a second unhandled rejection if some rows were never inserted.

Verification

The following test registers a listener for the unhandledRejection process event before running your function and asserts that zero rejections escaped. Use it in Jest or the Node.js built-in test runner.

const { processItems } = require('./processor');

test('no rejections escape processItems', async () => {
  const escaped = []; // collect any escaped rejections

  const handler = (reason) => escaped.push(reason);
  process.on('unhandledRejection', handler);

  const items = [
    { id: 'ok-1' },
    { id: 'fail-2' }, // this one throws inside fetchResource in the mock
    { id: 'ok-3' },
  ];

  await processItems(items);

  // Flush the microtask queue so any pending rejections fire before we assert
  await new Promise(resolve => setImmediate(resolve));

  process.off('unhandledRejection', handler);

  expect(escaped).toHaveLength(0); // zero rejections reached the global handler
});

A passing test means every rejection was caught at the iteration boundary and never propagated to the process level. If the test fails, escaped contains the exact reasons — useful for pinpointing which loop pattern is still broken.

Edge Cases & Gotchas

  • for await...of on a throwing async generator surfaces the error at the call site, not inside the generator. The throw inside the generator causes the for await to throw at the loop level. An inner try/catch wrapping only processItem(item) will not catch it — you need the outer try/catch shown in step 2 above.

  • AbortError from intentional cancellations should not be reported as failures. When you cancel a fetch via AbortController, the rejected reason has err.name === 'AbortError'. Check for this before routing to your error telemetry: if (err.name !== 'AbortError') telemetry.capture(err). Treating intentional cancels as errors inflates failure rates and triggers false alerts.

  • Promise.allSettled does not short-circuit. All promises run to completion even after early ones reject. If you launch 100 concurrent requests and the first 10 fail immediately, the remaining 90 still run. This is usually what you want for fault-tolerant batches, but it means you cannot use allSettled as a way to stop work early — use an AbortController and cancel in-flight requests inside the rejected filter if you need early termination.

  • Nested async loops need their own error boundary at each level. An inner loop’s catch block does not propagate to the outer loop’s catch. Each level of nesting is an independent error surface. If processItem itself contains a for...of loop over sub-items, that inner loop needs its own try/catch — a rejection there will not be caught by the outer loop’s boundary.

FAQ

Why does wrapping an entire forEach call in try/catch not work? forEach is synchronous. By the time the outer try block exits, every async callback has been invoked but none has resolved or rejected yet. The catch runs against synchronous errors only — the async rejections fire later, outside any error boundary.

When should I prefer Promise.all over Promise.allSettled? Use Promise.all when the operation is atomic and a single failure means the rest of the work is meaningless — database transactions, multi-step form submissions, or cases where you need all values to proceed. Use Promise.allSettled when partial success is acceptable and you want to process each outcome independently. See Node.js uncaughtException vs unhandledRejection for how each pattern affects process-level rejection events.

Can I use Array.prototype.map with Promise.allSettled instead of forEach? Yes — items.map(async item => ...) returns an array of Promises, which is exactly what Promise.allSettled expects. This is the idiomatic concurrent pattern: await Promise.allSettled(items.map(async item => { ... })). The important constraint is that the async callback passed to map must not itself contain an unguarded await — any rejection inside the callback becomes one of the settled outcomes, not an escaped rejection, because allSettled owns all the Promises.