Menu

JavaScript Async Error Handling: try/catch, Promises, and unhandledrejection

How errors actually flow through async JavaScript — try/catch with async/await, .catch on promises, and the traps that silently swallow failures.

Errors in Async Code Don't Work Like Sync Errors

In synchronous JavaScript, a thrown error bubbles up the call stack until some try/catch catches it — or the program crashes. Async code breaks that model. By the time a network request fails, the function that started it has already returned. There's no call stack to bubble up.

Promises solve this by giving errors their own channel. A promise can either fulfill with a value or reject with a reason. Rejection is the async equivalent of throwing. Everything in this page is about making sure rejections land somewhere you control, instead of disappearing.

index.js
Output
Click Run to see the output here.

The try/catch runs and exits cleanly. The rejection happens 50ms later, long after the try block is done. Nothing catches it. That's the trap.

try/catch Works Again With await

The moment you await a promise, a rejection becomes a thrown error inside the async function. A surrounding try/catch catches it like any synchronous throw:

index.js
Output
Click Run to see the output here.

This is the pattern to reach for first. await bridges the async world back to the familiar try/catch shape. Put the await calls that can fail inside the try, and handle them in the catch.

One detail worth noting: only the awaited call is covered. If you kick off a promise without awaiting it, errors still escape.

The Most Common Bug: Forgetting to await

If you call an async function without await (or without returning its promise), rejections slip past the surrounding try/catch:

index.js
Output
Click Run to see the output here.

The try block finishes successfully. The rejection happens on the next tick, with nothing to catch it. You'll see an "unhandled promise rejection" warning in the console.

The fix is always the same: await the call, or return the promise so the caller can await it.

index.js
Output
Click Run to see the output here.

.catch() Is the Other Side of the Same Coin

You can handle rejections without async/await by chaining .catch():

index.js
Output
Click Run to see the output here.

.catch(fn) is shorthand for .then(undefined, fn). It handles any rejection from earlier in the chain. A .catch() at the end of a chain is the async equivalent of a top-level try/catch — the last line of defense before the rejection becomes "unhandled."

Mixing the two styles is fine. A common pattern is to use async/await inside a function and let the caller attach .catch():

index.js
Output
Click Run to see the output here.

fetch Doesn't Reject on HTTP Errors

This one catches everyone at least once. fetch only rejects on network-level failures — DNS lookup failed, connection refused, request aborted. A 404 or 500 response is considered a successful fetch. The promise resolves; it just resolves with a response whose ok is false.

index.js
Output
Click Run to see the output here.

If you want HTTP errors in your catch block, check res.ok and throw explicitly:

index.js
Output
Click Run to see the output here.

This is boilerplate worth extracting into a helper once you find yourself writing it twice.

Promise.all Fails Fast; Promise.allSettled Doesn't

Promise.all takes an array of promises and resolves with an array of results — unless one rejects, in which case it rejects immediately with that error. The other promises keep running, but their results are thrown away.

index.js
Output
Click Run to see the output here.

Fail-fast is the right behavior when you need every result and a single failure makes the whole operation meaningless. When you want every outcome regardless — "try these five uploads, tell me which succeeded and which failed" — use Promise.allSettled:

index.js
Output
Click Run to see the output here.

allSettled never rejects. Each entry is either {status: "fulfilled", value} or {status: "rejected", reason}.

Rethrowing and Narrow Catches

Not every error belongs in the same handler. A common pattern is to catch, inspect, and rethrow anything you didn't expect:

index.js
Output
Click Run to see the output here.

Swallowing every error with a bare catch (err) {} hides real bugs. Catch what you can handle meaningfully; rethrow the rest.

Unhandled Rejections Are Your Safety Net

Even with careful code, something slips through eventually. Both Node.js and browsers expose a global hook for rejections nobody caught:

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

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

This isn't a replacement for proper handling — it's a last-resort log or telemetry hook. In modern Node.js, an unhandled rejection crashes the process by default, which is usually what you want in production. Log the error, then let the process die and restart clean.

A Practical Checklist

When an async function touches anything that can fail, ask yourself:

  • Is every risky await inside a try/catch, or is the returned promise handled by the caller with .catch()?
  • Am I actually awaiting the call, or did I fire-and-forget by accident?
  • For fetch specifically, am I checking res.ok before trusting the response?
  • When running things in parallel, is Promise.all the right tool, or do I want Promise.allSettled?
  • Is there a top-level .catch() or unhandledrejection handler so nothing disappears silently?

Get those five right and your async code stops surprising you with errors that vanish into the event loop.

Next: ES Modules

Async error handling rounds out the async chapter. Next, we move to how JavaScript code is organized across files — import, export, and the module system that underpins every modern project.

Frequently Asked Questions

How do you handle errors in an async function?

Wrap the await calls in a try/catch block. Any rejection from an awaited promise turns into a thrown error that catch receives. You can also let the error propagate and handle it at the call site with .catch() on the returned promise.

Why doesn't my try/catch catch the error?

Usually because the error happens in code that isn't awaited. If you call an async function without await (or without returning its promise), any rejection escapes the enclosing try/catch. Always await or return the promise you want errors from.

What happens if a promise rejects and nothing catches it?

You get an unhandled rejection. Node.js fires a unhandledRejection event and, in recent versions, crashes the process by default. Browsers fire window.onunhandledrejection and log a warning. Either way, attach a .catch() or handle it in a try/catch around an await.

How does Promise.all handle errors?

Promise.all rejects as soon as any input promise rejects, and the other promises keep running but their results are discarded. If you want every outcome regardless of failures, use Promise.allSettled — it resolves with an array of {status, value} or {status, reason} entries.

Learn to code with Coddy

GET STARTED