Skip to content
JavaScript js async 4 min read

Error Handling in Async Code

Asynchronous code fails in ways synchronous code never does: a network request times out, a file is missing, a JSON body is malformed. Because the failure happens later, a plain try/catch around the call that starts the work won’t catch it. JavaScript gives you two coordinated tools for this — try/catch with await, and .catch() on promises — and knowing when each applies (and how they interact with Promise.all, finally, and unhandled rejections) is the difference between resilient code and silent data loss.

try/catch with await

When you await a promise that rejects, the rejection is thrown as an exception at the await point. That means a regular try/catch block works exactly as it does for synchronous code — the rejected value becomes the caught error.

async function loadUser(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) {
    console.error("Failed to load user:", err.message);
    return null;
  }
}

Two subtleties matter here. First, fetch only rejects on network failures — a 404 or 500 resolves successfully, so you must check res.ok yourself and throw. Second, anything thrown inside the try (including your own throw) lands in the same catch, so validation errors and transport errors share one handler.

Always inspect response.ok after fetch. A failed HTTP status is a resolved promise, not a rejected one, so it slips past catch unless you throw.

.catch on promises

If you’re not using await — for example in a promise chain or a fire-and-forget call — attach .catch() to handle rejection. It receives the rejection reason and lets the chain continue with a recovered value.

fetch("/api/config")
  .then((res) => res.json())
  .then((config) => applyConfig(config))
  .catch((err) => {
    console.warn("Using default config:", err.message);
    applyConfig(DEFAULTS);
  });

A single .catch() at the end of a chain catches a rejection from any preceding .then(), much like a try/catch wrapping the whole sequence. The two styles are interchangeable; try/catch reads more naturally with await, while .catch() is convenient when you don’t want to make the surrounding function async.

Handling Promise.all rejections

Promise.all rejects as soon as any input promise rejects, and the others are abandoned (their results are discarded). Wrap it in try/catch to capture the first failure.

async function loadDashboard() {
  try {
    const [user, posts, stats] = await Promise.all([
      fetchUser(),
      fetchPosts(),
      fetchStats(),
    ]);
    render(user, posts, stats);
  } catch (err) {
    showError("Dashboard failed to load:", err);
  }
}

When you’d rather let every promise settle regardless of individual failures, use Promise.allSettled. It never rejects; instead each result is an object describing the outcome.

const results = await Promise.allSettled([fetchUser(), fetchPosts()]);
for (const result of results) {
  if (result.status === "fulfilled") {
    console.log("OK:", result.value);
  } else {
    console.error("Failed:", result.reason);
  }
}
CombinatorRejects when…Use it when
Promise.allany input rejectsyou need all results or none
Promise.allSettledneveryou want every outcome, success or failure
Promise.racethe first settled promise rejectsfirst-to-finish wins (e.g. timeouts)
Promise.anyall inputs reject (AggregateError)first success is enough

finally for cleanup

Both try/catch and promise chains support finally, which runs whether the operation succeeded or failed. Use it for cleanup that must happen unconditionally — hiding spinners, closing connections, re-enabling buttons.

async function submitForm(data) {
  setLoading(true);
  try {
    await postData("/api/form", data);
    showSuccess();
  } catch (err) {
    showError(err.message);
  } finally {
    setLoading(false);
  }
}

The finally block doesn’t receive the error or result and shouldn’t swallow them — a return inside finally overrides whatever the try/catch produced, which is a classic source of bugs.

Unhandled rejection pitfalls

A promise that rejects with no .catch() and no surrounding try/catch becomes an unhandled rejection. In Node.js this terminates the process by default; in browsers it logs a console error and fires a window event. The most common cause is forgetting await or dropping a returned promise.

// Bug: the rejection escapes — nothing awaits or catches it.
async function risky() {
  doAsyncThing(); // missing await
}

// Fix: await it inside try/catch, or attach .catch().
async function safe() {
  try {
    await doAsyncThing();
  } catch (err) {
    handle(err);
  }
}

You can install a global safety net to log anything that slips through, though it’s a last resort rather than a substitute for local handling.

// Node.js
process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
});

// Browser
window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled rejection:", event.reason);
  event.preventDefault(); // suppress the default console error
});

Output:

Unhandled rejection: Error: HTTP 500

Never await two independent promises in series just to catch them — start them together (Promise.all) and wrap the await in one try/catch. Serial awaiting is slower and abandons in-flight work on the first failure.

Best Practices

  • Always check response.ok after fetch; a bad HTTP status resolves, it does not reject.
  • Prefer try/catch with await for readability; reserve .catch() for chains and fire-and-forget calls.
  • Use Promise.allSettled when partial success is acceptable and Promise.all when you need everything or nothing.
  • Put unconditional cleanup in finally, and never return from it.
  • Re-throw or wrap errors you can’t handle locally rather than swallowing them — silent catch blocks hide real failures.
  • Add a global unhandledRejection / unhandledrejection listener for logging, but treat it as a backstop, not your primary strategy.
  • Always await (or explicitly .catch()) every promise you create so none can leak as an unhandled rejection.
Last updated June 1, 2026
Was this helpful?