async/await and error handling

black flat screen computer monitor

Async/await is the simplest, most readable way in modern JavaScript to work with asynchronous code. We are going to talk through why to use it, how it works, common errors, tooling and comparisons to other async patterns.

Why use async/await

  • Readability: async/await turns nested promise chains and callback hell into linear-looking code that’s easier to understand
  • Error flow: try/catch works naturally with awaits, so error handling is familiar
  • Composability: works well with Promise utilities (Promise.all, Promise.race) for concurrency control
  • Adoption: native in Node.js and modern browsers, supported by TypeScript, and easy to transpile

async/await basics

  • Mark a function async to allow using await inside it:
    • async function foo() { // }
  • Await pauses the async function until the awaited Promise settles:
    • const result = await fetch(url)
  • Await unwraps fulfilled values; if the Promise rejects, await throws the rejection reason as an exception that can be caught with try/catch

Basic examples

  1. Fetch API (browser)




async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const user = await res.json();
    return user;
  } catch (err) {
    // handle or rethrow
    console.error('getUser failed', err);
    throw err;
  }
}
  1. Node/DB example




async function createOrder(orderData) {
  try {
    await db.beginTransaction();
    const order = await db.insert('orders', orderData);
    await db.commit();
    return order;
  } catch (err) {
    await db.rollback();
    throw err;
  }
}

Error handling patterns

  1. Local try/catch (simple)
  • Use try/catch around code that might throw
  • Good for single logical operation or when you need to handle the error locally
  1. Bubble up (rethrow)
  • Catch to perform cleanup/logging, then rethrow to let callers decide




try {
  // ...
} catch (err) {
  log(err);
  throw err; // preserve stack
}
  1. Return error objects (functional)
  • Avoid exceptions by returning { value, error } or Result types — good for predictable flows and avoiding exceptions across module boundaries








async function safeGet(url) {
  try {
    return { value: await fetchJson(url), error: null };
  } catch (error) {
    return { value: null, error };
  }
}
  1. Top-level centralized handlers
  • In servers or UI apps, have a top-level error handler to convert uncaught errors into responses or UI notifications
  • Examples: Express error middleware, window.onerror, React Error Boundaries (for render errors — async in event handlers still needs try/catch)
  1. Promise utilities + aggregated errors
  • Promise.all rejects fast on the first rejection; Promise.allSettled shows all results; Promise.any resolves on first success or rejects with AggregateError
  • Use these depending on whether you prefer fail-fast or collecting partial results

Common problems and what to look for

  • Not awaiting a Promise: forget to use await leads to unhandled rejections or unexpected behavior
    • Example: const data = fetch(url); // data is Promise not resolved value
  • Mixing callback-style error handling with async/await incorrectly
  • UnhandledPromiseRejection: Node will warn; use global handlers in dev to catch
  • Blocking the event loop: await long-running CPU work — use worker threads or offload computation
  • Relying on sequential awaits when parallelism is intended:
    • Bad: const a = await p1; const b = await p2; // runs sequentially
    • Better: const [a, b] = await Promise.all([p1, p2]); // runs in parallel

Tips & best practices

  • Prefer top-level await only in modules where available and when it simplifies bootstrapping
  • Use Promise.all for concurrent independent tasks, use Promise.allSettled when you need results of all even if some fail
  • Always handle expected rejections: network, validation, timeouts
  • Add timeouts to network calls to avoid hanging awaits (AbortController in fetch)
  • Avoid swallowing errors silently, log or report them
  • Keep try blocks small, only wrap the await that can throw, not lots of unrelated code.
  • For retries, implement exponential backoff + jitter to reduce thundering herd problems
  • Use typed errors or error codes (class AppError extends Error) so handlers can decide behavior based on error type
  • When returning errors to users, avoid leaking implementation details (stack traces). Use user-friendly messages at boundaries

Tools and libraries that help

  • Node / Browser built-ins:
    • AbortController (fetch timeouts/cancel)
    • global Promise utilities
  • Fetch wrappers:
    • ky — small fetch wrapper with timeout, JSON handling
  • Retry helpers:
    • p-retry, promise-retry – retry with backoff
  • Error reporting:
    • Sentry, LogRocket, Datadog (for production error collection)
  • TypeScript:
    • Use Result types (fp-ts, neverthrow) or typed errors to make handling explicit.
  • Linters:
    • ESLint rules: no-floating-promises, @typescript-eslint/no-misused-promises
  • Test frameworks:
    • Jest, Vitest — test async functions with async/await and mocks
  • Utilities:
    • zod or io-ts for runtime validation of async responses

Patterns with examples

  1. Parallel fetch with fallback and aggregated handling




async function getData() {
  const sources = [fetchJson('/a'), fetchJson('/b')];
  const results = await Promise.allSettled(sources);
  const successes = results
    .filter(r => r.status === 'fulfilled')
    .map(r => r.value);
  if (successes.length === 0) throw new Error('All sources failed');
  return successes[0]; // first successful
}
  1. Retry with exponential backoff (simple)




async function retry(fn, retries = 3) {
  let attempt = 0;
  while (attempt < retries) {
    try {
      return await fn();
    } catch (err) {
      attempt++;
      if (attempt === retries) throw err;
      await new Promise(res => setTimeout(res, 2 ** attempt * 100 + Math.random() * 100));
    }
  }
}
  1. Handling streaming/long-running tasks with AbortController




async function fetchWithTimeout(url, ms = 5000) {
  const ac = new AbortController();
  const id = setTimeout(() => ac.abort(), ms);
  try {
    const res = await fetch(url, { signal: ac.signal });
    return await res.json();
  } finally {
    clearTimeout(id);
  }
}

Performance considerations

  • Awaiting sequentially is slower than running promises concurrently, use Promise.all where appropriate
  • Large numbers of concurrent promises can exhaust resources (sockets, DB connections). Use pooling or concurrency limits (p-limit)
  • Memory leaks: keep references to unresolved Promises carefully, don’t store long-lived arrays of unresolved async jobs

Error classification and handling strategy

  • Transient (network timeouts, rate limits): retry with backoff
  • Recoverable (missing cache entry): fallback or fetch master source
  • Fatal (auth failure, validation): return user-facing error, don’t retry
  • Unknown/internal (bugs): log, alert, and return generic error to users

Testing async code

  • Always write unit tests for both success and failure cases
  • Use mocks for network/DB: jest.mock, nock (Node), msw (browser & Node)
  • Test timeouts and retries by advancing fake timers or mocking retry helpers
  • Assert side effects: ensure cleanup code (finally blocks) ran correctly

Security and privacy considerations

  • Don’t leak sensitive data in error messages or logs.
  • Sanitize errors before sending them to the client.
  • Mask API keys and PII in error-reporting tools.

Comments

Leave a Reply