Error Handling in Async Code
Synchronous errors propagate up the call stack until a try / catch catches them. Asynchronous errors do not — they live inside promises and microtasks, and they will silently disappear if you do not actively handle them. This page is about catching async failures in the right place, and about the safety nets the runtime provides when you miss one.
Why async errors are different
A function that returns a promise has already returned by the time the error occurs. The stack that called it is long gone, so a try / catch wrapped around the call site sees nothing.
function load() {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error("boom")), 100);
});
}
try {
load(); // returns a pending promise — no error yet
} catch (err) {
console.log("never runs");
}The error has nowhere to go: load() returned a promise, the synchronous part is finished, and nobody is watching the promise. You need to attach a handler to that promise.
Catching with .catch
The simplest fix is a .catch at the end of every chain. Rejections skip every .then and land in the next .catch upstream.
load()
.then((value) => console.log("ok", value))
.catch((err) => console.log("failed:", err.message));try / catch around await
Inside an async function, the rejection is thrown as a normal exception — so the synchronous try / catch you already know works again. This is the cleanest pattern for most async code.
async function loadUser(id) {
try {
const response = await fetch(`/api/user/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
console.error("loadUser failed:", err);
throw err; // re-throw so callers know
}
}Forgotten errors in chains
Three patterns silently swallow rejections. Memorise them and you will save hours of debugging:
1. Missing return inside .then
Promise.resolve()
.then(() => {
fetch("/api/log"); // promise is dropped — its rejection vanishes
})
.catch((err) => console.log("never runs"));2. async callback in forEach
ids.forEach(async (id) => {
await processOne(id); // forEach throws away the returned promise
});
// errors in processOne never reach the surrounding try3. Fire-and-forget call
async function logOut() {
fetch("/api/logout", { method: "POST" }); // missing await
}In each case the rejection is unobserved. Fixes: return the inner promise, switch forEach to for...of or Promise.all(map), and await the fire-and-forget call.
The unhandledrejection safety net
If a promise rejects and no handler is attached by the time microtasks drain, the host fires an unhandledrejection event. Listening to it gives you a last chance to log, report or recover.
Browser
window.addEventListener("unhandledrejection", (event) => {
console.error("unhandled:", event.reason);
// event.preventDefault(); // suppress the default console error
reportToErrorTracker(event.reason);
});Node.js
process.on("unhandledRejection", (reason, promise) => {
console.error("unhandled rejection at", promise, "reason:", reason);
});Concurrent operations and partial failures
When you run several async tasks together, decide up front whether one failure should kill the batch (Promise.all) or whether you want each outcome reported (Promise.allSettled).
const results = await Promise.allSettled(urls.map((u) => fetch(u)));
const failed = results
.map((r, i) => ({ r, url: urls[i] }))
.filter(({ r }) => r.status === "rejected");
if (failed.length) {
console.warn(`${failed.length} of ${results.length} urls failed`);
}Errors across module boundaries
Two pragmatic conventions help large async codebases stay debuggable:
Wrap, do not swallow. When you catch in a library boundary, attach context and re-throw — use
new Error("message", { cause: original })so the original stack survives.Catch at the edge. Catch only where you can do something meaningful — log, retry, surface to the user. Interior code should rethrow.
Never
catchanawaitjust to ignore it unless you genuinely mean "I do not care if this fails" (and even then, log it).