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.okafterfetch. A failed HTTP status is a resolved promise, not a rejected one, so it slips pastcatchunless 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);
}
}
| Combinator | Rejects when… | Use it when |
|---|---|---|
Promise.all | any input rejects | you need all results or none |
Promise.allSettled | never | you want every outcome, success or failure |
Promise.race | the first settled promise rejects | first-to-finish wins (e.g. timeouts) |
Promise.any | all 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
awaittwo independent promises in series just to catch them — start them together (Promise.all) and wrap theawaitin onetry/catch. Serial awaiting is slower and abandons in-flight work on the first failure.
Best Practices
- Always check
response.okafterfetch; a bad HTTP status resolves, it does not reject. - Prefer
try/catchwithawaitfor readability; reserve.catch()for chains and fire-and-forget calls. - Use
Promise.allSettledwhen partial success is acceptable andPromise.allwhen you need everything or nothing. - Put unconditional cleanup in
finally, and neverreturnfrom it. - Re-throw or wrap errors you can’t handle locally rather than swallowing them — silent
catchblocks hide real failures. - Add a global
unhandledRejection/unhandledrejectionlistener 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.