Async Error Handling Done Right
Master async error handling in TypeScript — try/catch patterns, Promise.allSettled, retry strategies, error boundaries, and graceful degradation.
Async code is hard enough without errors. When errors enter the picture — network timeouts, API failures, race conditions — things get chaotic fast. Most async bugs aren’t logic errors. They’re missing error handlers, swallowed rejections, and assumptions that the network always works.
Here’s how to handle async errors properly in TypeScript, with patterns you can apply immediately.
The Basics: Try/Catch with Async/Await
Every await can throw. Treat it like a loaded gun:
// Dangerous — unhandled rejection if fetch fails
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data;
}
// Safe — handles both network errors and bad responses
async function getUser(id: string): Promise<User> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new ApiError(
`Failed to fetch user ${id}`,
response.status,
await response.text(),
);
}
return await response.json();
} catch (error) {
if (error instanceof ApiError) throw error;
throw new NetworkError(`Unable to reach user service`, { cause: error });
}
}
Notice: response.json() can also throw (if the body isn’t valid JSON). Always handle the full chain.
Custom Error Classes
Stop using plain Error for everything. Typed errors let callers make decisions:
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
options?: ErrorOptions,
) {
super(message, options);
this.name = this.constructor.name;
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} '${id}' not found`, 'NOT_FOUND', 404);
}
}
class ValidationError extends AppError {
constructor(
message: string,
public readonly fields: Record<string, string>,
) {
super(message, 'VALIDATION_ERROR', 400);
}
}
class RateLimitError extends AppError {
constructor(public readonly retryAfterMs: number) {
super('Rate limit exceeded', 'RATE_LIMITED', 429);
}
}
Now callers can handle errors by type:
try {
const user = await getUser(id);
} catch (error) {
if (error instanceof NotFoundError) {
return res.status(404).json({ error: 'User not found' });
}
if (error instanceof RateLimitError) {
res.setHeader('Retry-After', String(error.retryAfterMs / 1000));
return res.status(429).json({ error: 'Too many requests' });
}
// Unknown error — log and return 500
logger.error('Unexpected error fetching user', error);
return res.status(500).json({ error: 'Internal server error' });
}
Promise.allSettled: When Partial Failure Is OK
Promise.all fails fast — one rejection kills the whole batch. Use Promise.allSettled when you want results from everything that succeeded:
interface UserDashboard {
profile: User | null;
orders: Order[];
notifications: Notification[];
recommendations: Product[];
}
async function loadDashboard(userId: string): Promise<UserDashboard> {
const [profileResult, ordersResult, notificationsResult, recsResult] =
await Promise.allSettled([
fetchProfile(userId),
fetchOrders(userId),
fetchNotifications(userId),
fetchRecommendations(userId),
]);
// Log failures but don't crash the page
for (const result of [profileResult, ordersResult, notificationsResult, recsResult]) {
if (result.status === 'rejected') {
logger.warn('Dashboard component failed to load', { reason: result.reason });
}
}
return {
profile: profileResult.status === 'fulfilled' ? profileResult.value : null,
orders: ordersResult.status === 'fulfilled' ? ordersResult.value : [],
notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : [],
recommendations: recsResult.status === 'fulfilled' ? recsResult.value : [],
};
}
A helper to extract settled results cleanly:
function getSettledValue<T>(result: PromiseSettledResult<T>): T | undefined {
return result.status === 'fulfilled' ? result.value : undefined;
}
function getSettledValue<T>(result: PromiseSettledResult<T>, fallback: T): T {
return result.status === 'fulfilled' ? result.value : fallback;
}
Retry Strategies
Network calls fail. Services go down. Retries are essential — but naive retries can make things worse (thundering herd). Use exponential backoff with jitter:
interface RetryOptions {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
shouldRetry?: (error: unknown) => boolean;
}
async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions,
): Promise<T> {
const {
maxAttempts,
baseDelayMs,
maxDelayMs,
shouldRetry = () => true,
} = options;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts || !shouldRetry(error)) {
throw error;
}
// Exponential backoff with jitter
const exponentialDelay = baseDelayMs * Math.pow(2, attempt - 1);
const jitter = Math.random() * exponentialDelay * 0.1;
const delay = Math.min(exponentialDelay + jitter, maxDelayMs);
console.warn(
`Attempt ${attempt}/${maxAttempts} failed, retrying in ${Math.round(delay)}ms`,
error instanceof Error ? error.message : error,
);
await sleep(delay);
}
}
throw lastError;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
Usage:
const user = await withRetry(
() => fetchUser(userId),
{
maxAttempts: 3,
baseDelayMs: 500,
maxDelayMs: 5000,
shouldRetry: (error) => {
// Only retry network errors and 5xx, not 4xx
if (error instanceof RateLimitError) return true;
if (error instanceof ApiError && error.statusCode >= 500) return true;
if (error instanceof NetworkError) return true;
return false;
},
},
);
The Result Pattern: Errors as Values
Instead of throwing, return errors as values. This forces callers to handle both cases:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Usage
async function parseConfig(raw: string): Result<Config, ValidationError> {
try {
const parsed = JSON.parse(raw);
if (!parsed.apiUrl) {
return err(new ValidationError('Missing apiUrl', { apiUrl: 'required' }));
}
return ok({
apiUrl: parsed.apiUrl,
timeout: parsed.timeout ?? 5000,
});
} catch {
return err(new ValidationError('Invalid JSON', { config: 'must be valid JSON' }));
}
}
// Caller MUST handle both cases — TypeScript enforces this
const result = await parseConfig(rawInput);
if (!result.ok) {
console.error('Config error:', result.error.fields);
process.exit(1);
}
// TypeScript narrows: result.value is Config here
console.log('API URL:', result.value.apiUrl);
Graceful Degradation
Not every error deserves a crash. Design your system to degrade gracefully:
async function renderProductPage(productId: string): Promise<PageData> {
// Critical — fail the whole page if this fails
const product = await fetchProduct(productId);
// Non-critical — use fallbacks
const [reviews, relatedProducts, inventory] = await Promise.allSettled([
fetchReviews(productId),
fetchRelatedProducts(productId),
fetchInventory(productId),
]);
return {
product,
reviews: getSettledValue(reviews, []),
relatedProducts: getSettledValue(relatedProducts, []),
inventory: getSettledValue(inventory, { status: 'unknown' as const }),
degraded: [reviews, relatedProducts, inventory].some(
r => r.status === 'rejected',
),
};
}
The page renders even if reviews or recommendations are down. The degraded flag lets the UI show a “some features unavailable” banner.
Python Async Error Handling
The same patterns apply in Python with asyncio:
import asyncio
from dataclasses import dataclass
from typing import TypeVar, Generic
T = TypeVar("T")
@dataclass
class Result(Generic[T]):
value: T | None = None
error: Exception | None = None
@property
def ok(self) -> bool:
return self.error is None
async def with_retry(
fn,
max_attempts: int = 3,
base_delay: float = 0.5,
should_retry=lambda e: True,
):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
return await fn()
except Exception as e:
last_error = e
if attempt == max_attempts or not should_retry(e):
raise
delay = base_delay * (2 ** (attempt - 1))
await asyncio.sleep(delay)
raise last_error
# Gather with partial failure
async def load_dashboard(user_id: str) -> dict:
results = await asyncio.gather(
fetch_profile(user_id),
fetch_orders(user_id),
fetch_notifications(user_id),
return_exceptions=True,
)
profile, orders, notifications = results
return {
"profile": profile if not isinstance(profile, Exception) else None,
"orders": orders if not isinstance(orders, Exception) else [],
"notifications": notifications if not isinstance(notifications, Exception) else [],
}
Key Takeaways
- Every
awaitcan throw — handle it or let it propagate intentionally. - Use typed errors — catch by type, not by message string.
Promise.allSettledoverPromise.allwhen partial failure is acceptable.- Retry with backoff and jitter — not blindly, only for transient failures.
- Degrade gracefully — show what you can, flag what’s missing.
- Never swallow errors silently — at minimum, log them.
The goal isn’t to prevent all errors — it’s to handle them in a way that keeps your application running, gives users a reasonable experience, and gives you the information to fix the root cause.