Async Error Boundaries
HardStructured error handling for asynchronous code goes far beyond wrapping everything in try/catch. It includes distinguishing operational errors from programmer errors, aggregating results from Promise.allSettled, implementing circuit breakers to avoid cascading failures, handling global unhandled rejections, and designing graceful degradation strategies. These patterns ensure your application fails predictably and recovers gracefully.
Interactive Visualization
1async function getUser(id) {2 const res = await fetch("/api/users/" + id)3 if (!res.ok) throw new Error("Not found")4 return res.json()5}67async function getUserPosts(id) {8 const user = await getUser(id) // throws!9 return fetch("/api/posts/" + user.id)10}1112try {13 const posts = await getUserPosts(999)14} catch (error) {15 console.error(error.message)16}
Understanding Async Error Boundaries
Error handling in asynchronous JavaScript requires a fundamentally different mindset from synchronous try/catch. In synchronous code, an unhandled exception immediately crashes the program with a clear stack trace. In async code, a forgotten await or missing .catch() creates a silently rejected Promise that may go unnoticed until it corrupts application state or loses user data. The unhandledrejection event exists specifically to catch these cases, and every production application should register a handler.
The most important conceptual distinction in async error handling is between operational errors and programmer errors. Operational errors — network timeouts, HTTP 503 responses, rate limit hits — are expected conditions that your code should handle gracefully through retries, fallbacks, or circuit breakers. Programmer errors — TypeErrors, null dereferences, invalid arguments — are bugs that should crash loudly in development and be reported to error tracking in production. Conflating the two leads to either fragile code that crashes on transient network blips or silent code that hides real bugs.
Circuit breakers, error aggregation with Promise.allSettled, and graceful degradation form a toolkit for building resilient async systems. A circuit breaker stops calling a failing service after consecutive errors, preventing cascading failures across your infrastructure. Promise.allSettled lets you fire multiple requests in parallel and handle each result individually, so one failure does not abort the entire operation. These patterns are frequently discussed in system design and senior frontend interviews, where candidates are expected to reason about failure modes and recovery strategies.
Key Points
- Operational errors (network timeouts, 404s) are expected and recoverable; programmer errors (TypeError, null access) are bugs
- The unhandledrejection event catches Promises that reject without a handler — essential for logging and crash prevention
- Promise.allSettled returns every result regardless of individual failures, enabling partial-success workflows
- Circuit breakers stop calling a failing service after a threshold, preventing cascading failures
- Error aggregation collects multiple async failures into a single AggregateError for unified handling
- Graceful degradation returns cached or default data when a service is unavailable
- Error boundaries in async workflows isolate failures so one bad task does not abort the entire pipeline
Code Examples
Circuit Breaker
class CircuitBreaker { constructor(fn, options = {}) { this.fn = fn; this.failureCount = 0; this.threshold = options.threshold ?? 5; this.resetTimeMs = options.resetTimeMs ?? 30000; this.state = 'CLOSED'; // CLOSED | OPEN | HALF_OPEN this.nextAttempt = 0; } async call(...args) { if (this.state === 'OPEN') { if (Date.now() < this.nextAttempt) { throw new Error('Circuit is OPEN — request blocked'); } this.state = 'HALF_OPEN'; } try { const result = await this.fn(...args); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } onSuccess() { this.failureCount = 0; this.state = 'CLOSED'; } onFailure() { this.failureCount++; if (this.failureCount >= this.threshold) { this.state = 'OPEN'; this.nextAttempt = Date.now() + this.resetTimeMs; } } } const fetchUser = new CircuitBreaker( (id) => fetch(`/api/users/${id}`).then(r => r.json()), { threshold: 3, resetTimeMs: 10000 } );
A circuit breaker tracks consecutive failures. After crossing the threshold it blocks requests for a cooldown period, then allows one test request (HALF_OPEN) to see if the service recovered.
Error-Safe Promise.all Wrapper
async function safeAll(promises, options = {}) { const { fallback = null, onError } = options; const results = await Promise.allSettled(promises); return results.map((result, index) => { if (result.status === 'fulfilled') { return result.value; } if (onError) onError(result.reason, index); return fallback; }); } // Usage: fetch multiple resources, never fail entirely const [user, posts, notifications] = await safeAll( [ fetchUser(userId), fetchPosts(userId), fetchNotifications(userId), ], { fallback: null, onError: (err, i) => console.warn(`Task ${i} failed:`, err), } ); // Render what succeeded, show fallback for what failed if (user) renderProfile(user); if (posts) renderFeed(posts); if (!notifications) renderNotificationsFallback();
Wrapping Promise.allSettled with a fallback value and error callback gives you partial-success semantics without losing visibility into failures.
Global Unhandled Rejection Handler
// Browser window.addEventListener('unhandledrejection', (event) => { event.preventDefault(); // Prevent default console error const error = event.reason; // Distinguish error types if (error instanceof TypeError || error instanceof ReferenceError) { // Programmer error — report to error tracking reportToSentry(error); } else { // Operational error — log and recover console.warn('Unhandled async error:', error.message); } }); // Node.js process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection at:', promise, 'reason:', reason); // In production, log and continue // In development, crash fast to surface bugs if (process.env.NODE_ENV === 'development') { process.exit(1); } }); // Common cause: forgetting to await or .catch() async function leakyFunction() { fetchData(); // No await, no .catch() — rejection is unhandled! }
Global handlers catch Promises that slip through without error handling. Separating operational from programmer errors lets you log the former and crash on the latter during development.
Common Mistakes
- Swallowing errors silently with empty catch blocks, hiding bugs that surface later as mysterious state corruption
- Treating all errors the same — operational errors (timeout, 503) need retry logic, programmer errors (TypeError) need bug fixes
- Not attaching an unhandledrejection handler, missing errors from forgotten awaits
- Using Promise.all when partial success is acceptable — Promise.allSettled is the right tool
- Implementing a circuit breaker without a HALF_OPEN state, permanently blocking a recovered service
Interview Tips
- Distinguish operational errors (expected, recoverable) from programmer errors (bugs) — this is a key senior-level concept
- Know the three circuit breaker states: CLOSED (normal), OPEN (blocking), HALF_OPEN (testing recovery)
- Explain how Promise.allSettled differs from Promise.all and when each is appropriate
- Mention that unhandledrejection is the async equivalent of window.onerror for Promises