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.
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:
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:
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.
.catch() Is the Other Side of the Same Coin
You can handle rejections without async/await by chaining .catch():
.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():
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.
If you want HTTP errors in your catch block, check res.ok and throw explicitly:
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.
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:
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:
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
awaitinside atry/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
fetchspecifically, am I checkingres.okbefore trusting the response? - When running things in parallel, is
Promise.allthe right tool, or do I wantPromise.allSettled? - Is there a top-level
.catch()orunhandledrejectionhandler 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.