Best practices for try/catch in async loops
Direct resolution for swallowed errors and unhandled promise rejections in iterative async flows. This guide covers sequential versus concurrent execution boundaries. It provides observability-safe fallback patterns and exact migration paths from broken array methods.
Key implementation priorities:
- Isolate
try/catchper iteration to prevent single-point cascade failure. - Replace
Array.prototype.forEachwithfor...offor nativeawaitsupport. - Use
Promise.allSettledfor concurrent batches to guarantee partial success. - Route telemetry synchronously inside
catchblocks to avoid blocking the event loop.
Understanding Core JavaScript Error Handling & Boundaries establishes the foundation for these execution models and propagation limits.
Sequential Execution: for…of with Isolated try/catch
Maintain synchronous control flow by awaiting directly inside the loop body. Each iteration requires an independent error boundary. This prevents a single rejection from terminating the entire sequence.
const processItems = async (items) => {
for (const item of items) {
try {
await fetchResource(item.id);
} catch (err) {
telemetry.capture(err, { context: item.id });
}
}
};
The try/catch block wraps only the awaited operation. Rejections are isolated to the current iteration. Loop state remains intact for accurate progress tracking.
Concurrent Execution: Promise.allSettled vs Promise.all
Batch operations require strict rejection handling to avoid partial data loss. Promise.all fails fast on the first rejection. It discards all successfully resolved values.
Switch to Promise.allSettled for deterministic batch processing. It returns a status array for every promise. You can filter rejected results for targeted retry logic.
const processBatch = async (items) => {
const results = await Promise.allSettled(
items.map(item => fetchResource(item.id))
);
const failures = results.filter(r => r.status === 'rejected');
failures.forEach(f => telemetry.capture(f.reason));
};
This pattern guarantees all promises resolve or reject. Partial success is preserved. Failure aggregation becomes structured and queryable.
Anti-Pattern Resolution: Array.prototype.forEach with async
Array.prototype.forEach ignores async callback promises. It executes the callback synchronously and returns undefined immediately.
// BROKEN: Rejections are unhandled
items.forEach(async (item) => {
await fetchResource(item.id);
});
Async callbacks run detached from the iterator. Rejections escape to the global scope. This triggers unhandled rejection events and breaks observability context.
Refactor to for...of for sequential flows. Use Promise.allSettled for concurrent execution. This enforces deterministic boundaries and prevents silent failures. Review Handling Unhandled Promise Rejections in Modern JS for fallback mechanisms when local boundaries fail.
Observability & Telemetry Routing in Catch Blocks
Error payloads must route without introducing latency or memory leaks. Extract stack traces immediately before any async operations.
Use structured logging to attach correlation IDs. Keep catch blocks lightweight. Heavy I/O inside error handlers starves the event loop.
const processWithTelemetry = async (items) => {
for (const item of items) {
try {
await fetchResource(item.id);
} catch (err) {
const payload = {
error: err.message,
stack: err.stack,
correlationId: item.correlationId,
timestamp: Date.now()
};
// Synchronous dispatch to in-memory buffer or non-blocking queue
telemetry.push(payload);
}
}
};
Synchronous routing guarantees capture before process termination. Avoid await inside the catch block. Dereference large error objects after dispatch to prevent heap retention.
Common Mistakes
- Wrapping entire loop in single try/catch: Halts iteration on first rejection. Leaves remaining items unprocessed. Masks partial failure states.
- Using Array.prototype.forEach with async callbacks: Does not await promises. Rejections escape to global scope. Triggers
unhandledRejectionevents and breaks observability context. - Omitting await on Promise.all: Returns a pending promise without blocking execution. Subsequent code runs before resolution. Causes race conditions and swallowed errors.
FAQ
Why does try/catch not work with Array.prototype.map?
map executes synchronously and returns an array of pending promises. Errors must be handled via Promise.allSettled or by awaiting each promise individually.
How to preserve correlation IDs across async loop iterations?
Attach the ID to the error context inside the catch block before telemetry dispatch. Avoid async operations inside catch to prevent event loop starvation.
Should I use Promise.all or Promise.allSettled for background jobs?
Use Promise.allSettled for fault-tolerant background processing. Promise.all is strictly for atomic transactions where any failure requires full rollback.
How to prevent memory leaks when catching errors in tight loops? Ensure caught error objects are dereferenced after logging. Avoid storing full stack traces in long-lived arrays. Extract only necessary metadata for telemetry.