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

  1. Every await can throw — handle it or let it propagate intentionally.
  2. Use typed errors — catch by type, not by message string.
  3. Promise.allSettled over Promise.all when partial failure is acceptable.
  4. Retry with backoff and jitter — not blindly, only for transient failures.
  5. Degrade gracefully — show what you can, flag what’s missing.
  6. 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.