Loading content...
Async operations fail. Networks time out. Servers return errors. External services become unavailable. Databases reject queries. Robust async code isn't about preventing failures—it's about handling them gracefully.
In synchronous code, we have try/catch—a well-understood model where exceptions propagate up the call stack until caught. But in async code, the 'call stack' is fragmented across time. When does an error get caught? Where does it propagate? What happens to pending operations when one fails?
These questions have precise answers in the Future/Promise model, but the answers aren't always intuitive. Mastering async error handling separates production-grade code from code that 'works on my machine.'
By the end of this page, you will deeply understand how errors propagate through Promise chains, how to recover from errors gracefully, how to implement cleanup with finally(), how to handle errors in parallel operations, and how to avoid the most common async error handling pitfalls that plague production systems.
Understanding how errors flow through Promise chains is fundamental to writing correct async code. The mental model is straightforward once you internalize it:
The Propagation Rules:
.then() handlers are skipped for rejected Promises.catch() handlers catch rejections and can recover.catch() returning a value) becomes fulfilled.catch() throwing) continues propagating.finally() always runs, regardless of fulfillment or rejection1234567891011121314151617181920212223242526272829303132333435363738
// Visualizing error propagation through a Promise chain fetchUser(userId) .then(user => { console.log('A: Got user'); // Runs if fetchUser succeeds return fetchOrders(user.id); }) .then(orders => { console.log('B: Got orders'); // Runs if fetchOrders succeeds return processOrders(orders); }) .then(result => { console.log('C: Processed'); // Runs if processOrders succeeds return result; }) .catch(error => { console.log('D: Error caught'); // Catches ANY error from above // What happens in .catch() determines the chain's fate: // - Return a value: chain continues fulfilled with that value // - Throw/reject: chain continues rejected }) .finally(() => { console.log('E: Cleanup'); // ALWAYS runs }); // Scenario 1: fetchUser fails// Output: D: Error caught, E: Cleanup// A, B, C are all skipped // Scenario 2: fetchOrders fails// Output: A: Got user, D: Error caught, E: Cleanup// B, C are skipped // Scenario 3: All succeed// Output: A: Got user, B: Got orders, C: Processed, E: Cleanup// D is skipped // Key insight: Error "jumps" to the next .catch(), skipping all .then()sError Propagation with async/await:
With async/await, error propagation maps directly to try/catch semantics, making it more intuitive:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// async/await error handling maps to try/catch async function processUserOrders(userId: string): Promise<Result> { try { // Any await that rejects throws in this context const user = await fetchUser(userId); // Can throw const orders = await fetchOrders(user.id); // Can throw const result = await processOrders(orders); // Can throw return result; } catch (error) { // Catches errors from ANY of the awaits above console.error('Processing failed:', error); // Decide how to handle: // Option 1: Recover with a fallback return createEmptyResult(); // Option 2: Re-throw to propagate // throw error; // Option 3: Throw a different error // throw new ProcessingError('Failed to process orders', { cause: error }); } finally { // Always runs, regardless of success/failure cleanupResources(); }} // Scoped error handling: Different handlers for different phasesasync function robustProcessing(userId: string): Promise<Result> { let user: User; // Phase 1: User fetch with specific recovery try { user = await fetchUser(userId); } catch (error) { if (error.code === 'NOT_FOUND') { user = createGuestUser(); // Recover for this phase } else { throw error; // Propagate unexpected errors } } // Phase 2: Orders with different recovery let orders: Order[]; try { orders = await fetchOrders(user.id); } catch (error) { if (error.code === 'TIMEOUT') { orders = await fetchOrdersFromCache(user.id); // Fallback } else { throw error; } } // Phase 3: Processing (no local recovery, errors propagate) return processOrders(orders);}If a Promise rejects and there's no .catch() handler, the rejection is 'unhandled.' In Node.js, this prints a warning and (in newer versions) can crash the process. In browsers, it logs to console. Every Promise chain should end with error handling.
Catching errors is only half the story. What you do with caught errors determines your system's resilience. Here are the essential recovery patterns:
Pattern: When an operation fails, return a default/fallback value instead of propagating the error. The consumer doesn't know (or care) that a fallback was used.
12345678910111213141516171819202122232425262728293031323334353637383940414243
// Fallback value pattern // Simple: Return a default on any errorconst settings = await fetchUserSettings(userId) .catch(() => DEFAULT_SETTINGS); // Typed: Ensure fallback matches expected typeasync function getConfig<T>( key: string, defaultValue: T): Promise<T> { try { return await configService.get<T>(key); } catch (error) { logger.warn(`Config ${key} not found, using default`); return defaultValue; }} // Usageconst timeout = await getConfig('api.timeout', 5000);const retries = await getConfig('api.retries', 3); // Conditional fallback: Only for certain errorsasync function fetchWithCacheFallback<T>( key: string, fetcher: () => Promise<T>): Promise<T> { try { const fresh = await fetcher(); await cache.set(key, fresh); // Update cache on success return fresh; } catch (error) { if (isNetworkError(error)) { const cached = await cache.get<T>(key); if (cached) { logger.info(`Using cached value for ${key}`); return cached; } } throw error; // Propagate non-network errors and cache misses }}Production systems often layer multiple recovery patterns: retry transient failures, fall back to cache on persistent failures, and trip a circuit breaker when the service is down. Each layer catches what the previous layer couldn't handle.
Parallel operations add complexity to error handling. When multiple Promises run concurrently, errors can overlap, compound, or require special coordination. Understanding the error behavior of each composition pattern is essential.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
// Error handling differences in parallel operations // Promise.all: FAIL-FAST - first rejection rejects the aggregateasync function loadDashboard(userId: string) { try { const [user, orders, notifications] = await Promise.all([ fetchUser(userId), // If this fails... fetchOrders(userId), // ...these results are LOST fetchNotifications(userId), // ...even if they succeed ]); return { user, orders, notifications }; } catch (error) { // Only get the FIRST error // Don't know if others failed or succeeded throw new DashboardError('Failed to load dashboard', { cause: error }); }} // Promise.allSettled: Get ALL outcomes, handle individuallyasync function loadDashboardResilient(userId: string): Promise<Dashboard> { const results = await Promise.allSettled([ fetchUser(userId), fetchOrders(userId), fetchNotifications(userId), ]); const [userResult, ordersResult, notificationsResult] = results; // Must have user (critical) if (userResult.status === 'rejected') { throw new DashboardError('Cannot load dashboard without user', { cause: userResult.reason }); } return { user: userResult.value, // Fallback for non-critical data orders: ordersResult.status === 'fulfilled' ? ordersResult.value : [], notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : [], // Report degraded state degraded: ordersResult.status === 'rejected' || notificationsResult.status === 'rejected', };} // Promise.any: AggregateError contains ALL failures (but only if ALL fail)async function fetchFromAnyMirror(url: string): Promise<Response> { try { return await Promise.any([ fetch(`https://mirror1.example.com${url}`), fetch(`https://mirror2.example.com${url}`), fetch(`https://mirror3.example.com${url}`), ]); } catch (error) { if (error instanceof AggregateError) { // All mirrors failed - error.errors contains all failures console.error('All mirrors failed:', error.errors.map(e => e.message)); // Analyze: are they all the same error? const uniqueErrors = [...new Set(error.errors.map(e => e.message))]; if (uniqueErrors.length === 1) { throw new Error(`All mirrors returned: ${uniqueErrors[0]}`); } else { throw new Error(`Multiple failures: ${uniqueErrors.join(', ')}`); } } throw error; }} // Per-operation error handling with Promise.allasync function processWithIndividualRecovery(items: Item[]): Promise<Result[]> { // Wrap each operation with its own error handling const wrappedOperations = items.map(async (item, index) => { try { return await processItem(item); } catch (error) { // Per-item recovery logger.warn(`Item ${index} failed, using fallback`, error); return createFallbackResult(item); } }); // Now Promise.all never rejects - each operation handles its own errors return Promise.all(wrappedOperations);}| Pattern | When Some Fail | When All Fail | Error Access |
|---|---|---|---|
| Promise.all() | Rejects immediately with first error | Rejects with first error | Single error only |
| Promise.allSettled() | Resolves with mixed results | Resolves with all rejections | All errors in results array |
| Promise.race() | Depends on timing (first settles) | Rejects with first error | Single error only |
| Promise.any() | Resolves with first success | Rejects with AggregateError | All errors in AggregateError.errors |
When Promise.all fails fast, the other operations continue running. If they acquired resources (connections, locks, files), those resources won't be automatically cleaned up. Consider using AbortController to cancel pending operations when one fails, or use Promise.allSettled with explicit cleanup.
Resources must be released whether operations succeed or fail: database connections closed, file handles freed, spinners stopped, locks released. The finally() pattern ensures cleanup happens regardless of outcome.
Key property: finally() receives no arguments—it doesn't know if the Promise fulfilled or rejected. It executes cleanup and passes through the original result/error. If finally() itself throws, that error replaces the original.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// Cleanup patterns with finally // Basic pattern: UI loading stateasync function loadData(): Promise<Data> { showLoadingSpinner(); try { const data = await fetchData(); return data; } finally { hideLoadingSpinner(); // Always hide, success or failure }} // Resource acquisition patternasync function withConnection<T>( transaction: (conn: Connection) => Promise<T>): Promise<T> { const connection = await pool.getConnection(); try { return await transaction(connection); } finally { connection.release(); // ALWAYS release, even on error }} // Usageconst result = await withConnection(async (conn) => { await conn.query('BEGIN'); try { await conn.query('INSERT INTO orders ...'); await conn.query('UPDATE inventory ...'); await conn.query('COMMIT'); return { success: true }; } catch (error) { await conn.query('ROLLBACK'); throw error; }}); // Connection is released regardless of commit or rollback // Multiple resource cleanupasync function processFile(path: string): Promise<void> { const file = await openFile(path); const tempFile = await createTempFile(); try { const content = await file.read(); const processed = await transform(content); await tempFile.write(processed); await tempFile.moveTo(path); // Atomic replace } finally { // Clean up all resources await Promise.allSettled([ file.close(), tempFile.delete() // Delete temp even if move succeeded ]); // Note: Using allSettled so one cleanup failure // doesn't prevent other cleanups }} // finally() preserves the original resultasync function example() { const result = await Promise.resolve('success') .finally(() => console.log('cleanup')); // result === 'success' (finally doesn't change it) try { await Promise.reject(new Error('failure')) .finally(() => console.log('cleanup')); } catch (e) { // e.message === 'failure' (original error preserved) }} // Caution: throwing in finally REPLACES the original errorasync function dangerousFinally() { try { await Promise.reject(new Error('original error')) .finally(() => { throw new Error('cleanup error'); // DON'T DO THIS }); } catch (e) { // e.message === 'cleanup error' — original error is LOST! }} // Safe cleanup: catch errors in finallyasync function safeCleanup() { await someOperation() .finally(async () => { try { await cleanup(); } catch (cleanupError) { logger.error('Cleanup failed', cleanupError); // Don't re-throw — preserve original error } });}For complex resources, implement a using or withResource pattern that guarantees cleanup. This is similar to Python's with statement, C#'s using, or Java's try-with-resources. TypeScript 5.2+ adds explicit resource management with using declarations.
Async error handling has several subtle traps that catch even experienced developers. Learn to recognize and avoid these patterns:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
// PITFALL 1: Swallowing errors silently// ❌ BAD: Error is caught but ignoredasync function dangerous() { try { await riskyOperation(); } catch { // Nothing here - error silently disappears }} // ✅ GOOD: At minimum, log the errorasync function better() { try { await riskyOperation(); } catch (error) { logger.error('Operation failed', error); // Then either recover or re-throw }} // PITFALL 2: Not returning the Promise / forgetting awaitfunction forgetfulAsync() { // ❌ BAD: Promise returned but not awaited - caller can't catch errors riskyOperation(); // Fire-and-forget, errors lost!} async function alsoBad() { // ❌ BAD: Caller gets undefined, not the Promise riskyOperation(); // Same problem} async function correct() { // ✅ GOOD: Caller receives the Promise return riskyOperation();} // PITFALL 3: async function without try-catch expecting caller to handleasync function naiveHandler(req, res) { // ❌ BAD: If fetchData rejects, error bubbles up to Express // which may respond with 500 and log stack trace const data = await fetchData(req.params.id); res.json(data);} async function properHandler(req, res, next) { // ✅ GOOD: Explicit error handling try { const data = await fetchData(req.params.id); res.json(data); } catch (error) { next(error); // Pass to error middleware }} // PITFALL 4: Mixing callbacks and Promises incorrectlyasync function mixedUp(callback) { // ❌ BAD: async + callback = confusing error paths try { const result = await fetchData(); callback(null, result); // Callback might throw! } catch (error) { callback(error); // Error in callback won't be caught }} // ✅ GOOD: Pick one model, convert if necessaryasync function promiseOnly() { return fetchData(); // Let caller await} // PITFALL 5: Unhandled Promise in event handlers / callbacksbutton.addEventListener('click', async () => { // ❌ BAD: If this rejects, no one catches it await saveData();}); button.addEventListener('click', async () => { // ✅ GOOD: Handle errors in event handlers try { await saveData(); } catch (error) { showErrorToUser(error.message); }}); // PITFALL 6: Not handling errors in Promise.all constituentsasync function allOrNothing(ids: string[]) { // ❌ BAD: One failure loses all results return Promise.all(ids.map(id => fetchItem(id)));} async function resilientFetch(ids: string[]) { // ✅ GOOD: Handle each individually const results = await Promise.allSettled(ids.map(id => fetchItem(id))); return results .filter((r): r is PromiseFulfilledResult<Item> => r.status === 'fulfilled') .map(r => r.value);} // PITFALL 7: Catching too broadlyasync function tooGenerous() { try { const result = await complexOperation(); return processResult(result); } catch { // ❌ BAD: Catches EVERYTHING - network errors, bugs, type errors return fallback; }} async function selective() { try { const result = await complexOperation(); return processResult(result); } catch (error) { // ✅ GOOD: Only recover from expected errors if (error instanceof NetworkError) { return fallback; } throw error; // Unexpected errors should crash loudly }}Async error handling bugs are often invisible in development where networks are fast and services rarely fail. They surface in production under load, with intermittent failures, or with edge-case inputs. Test error paths explicitly—they are code paths too.
Here's a comprehensive checklist for production-grade async error handling:
.catch() at the end, try/catch with await, or documented propagation to caller.throw new SpecificError('Context', { cause: originalError }).unhandledRejection event. In production, these should be alerts.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
// Comprehensive example combining best practices // Custom error types for different failure modesclass ValidationError extends Error { constructor(message: string, public field: string) { super(message); this.name = 'ValidationError'; }} class ServiceError extends Error { constructor( message: string, public service: string, public recoverable: boolean, options?: ErrorOptions ) { super(message, options); this.name = 'ServiceError'; }} // Production-grade async functionasync function processOrder(orderData: OrderInput): Promise<OrderResult> { // Validate first (fail fast for invalid input) const validationResult = validateOrder(orderData); if (!validationResult.valid) { throw new ValidationError(validationResult.message, validationResult.field); } logger.info('Processing order', { orderId: orderData.id }); try { // Critical path: payment const payment = await withRetry( () => paymentService.process(orderData.payment), { maxAttempts: 3, initialDelay: 500, maxDelay: 5000 } ); // Non-critical: inventory update (with fallback) let inventory: InventoryResult; try { inventory = await inventoryService.reserve(orderData.items); } catch (error) { logger.warn('Inventory service degraded, using fallback', { error }); inventory = await inventoryFallback.reserve(orderData.items); } // Non-critical: notification (fire and forget, but monitored) notificationService.sendConfirmation(orderData.customerId) .catch(error => { // Don't fail order for notification failure logger.error('Notification failed', { orderId: orderData.id, error }); metrics.increment('notification.failures'); }); return { success: true, payment, inventory }; } catch (error) { // Add context before propagating throw new ServiceError( `Order processing failed: ${error.message}`, 'order-processor', error instanceof NetworkError, // Recoverable? { cause: error } ); }} // API handler with proper error boundariesapp.post('/orders', async (req, res, next) => { try { const result = await processOrder(req.body); res.status(201).json(result); } catch (error) { // Transform errors to appropriate HTTP responses if (error instanceof ValidationError) { res.status(400).json({ error: 'Validation failed', field: error.field, message: error.message }); } else if (error instanceof ServiceError && error.recoverable) { res.status(503).json({ error: 'Service temporarily unavailable', retryAfter: 30 }); } else { // Unexpected error - log and return 500 logger.error('Unexpected error in order handler', { error }); res.status(500).json({ error: 'Internal server error' }); } }});We've covered the complete landscape of async error handling—from propagation semantics to recovery patterns to production best practices. Let's consolidate the key learnings:
.then() handlers and flow to the next .catch() or try/catch block..catch() fulfill the chain; throwing continues propagation. Choose based on whether recovery is possible.Module Complete:
You've now mastered the Future/Promise pattern comprehensively:
This knowledge forms a critical foundation for concurrent programming in any language. The patterns you've learned—Promises, composition, error propagation—appear in every modern async system, from web browsers to distributed databases to mobile applications.
Congratulations! You've mastered the Future/Promise pattern—one of the most important abstractions in modern software engineering. You understand not just the mechanics but the why: why blocking fails at scale, why composition matters, and why error handling is the difference between code that works and code that works in production. Apply these patterns to write async code that is readable, maintainable, and resilient.