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.
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...ofon a throwing async generator surfaces the error at the call site, not inside the generator. Thethrowinside the generator causes thefor awaitto throw at the loop level. An innertry/catchwrapping onlyprocessItem(item)will not catch it — you need the outertry/catchshown in step 2 above. -
AbortErrorfrom intentional cancellations should not be reported as failures. When you cancel a fetch viaAbortController, the rejected reason haserr.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.allSettleddoes 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 useallSettledas a way to stop work early — use anAbortControllerand cancel in-flight requests inside therejectedfilter if you need early termination. -
Nested async loops need their own error boundary at each level. An inner loop’s
catchblock does not propagate to the outer loop’scatch. Each level of nesting is an independent error surface. IfprocessItemitself contains afor...ofloop over sub-items, that inner loop needs its owntry/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.
Related
- Handling Unhandled Promise Rejections in Modern JS — the parent guide covering global interception strategies for rejections that do escape
- How to Log Custom Error Properties Without Blooming Payloads — structuring the error objects you catch inside these loops for telemetry
- Node.js uncaughtException vs unhandledRejection — what happens at the process level when a loop rejection escapes all local boundaries
- Debugging Race Conditions in Async Error Handlers — diagnosing timing issues that arise when concurrent loops share mutable state
- Core JavaScript Error Handling & Boundaries — full reference for synchronous and asynchronous error boundaries across environments