Async error handling in JavaScript: try/catch done right
Unhandled promise rejections crash Node.js services and leave browser apps quietly broken. Catch async errors with try/catch, .catch and a global safety net.
Asynchronous code fails differently from synchronous code. An error thrown inside a promise does not travel up the call stack. It becomes a rejected promise, and if nothing handles that rejection, a browser app keeps running in a broken state while a Node.js process exits.
Most of these failures come from three small habits. Fix them and async code becomes predictable.
Why try/catch sometimes misses the error
try/catch only sees a rejection when the promise is awaited inside the try block.
async function loadUser(id) {
try {
fetchUser(id); // not awaited, so a rejection escapes the try block
} catch (err) {
console.error(err); // never runs
}
}
Add await and the same catch block starts working. The same rule applies to return fetchUser(id): write return await fetchUser(id) when you want the surrounding try to handle the failure.
The pattern for async/await
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
return await res.json();
} catch (err) {
logger.error({ err, id }, "Could not load user");
throw err; // let the caller decide what the user sees
}
}
Two details matter here:
fetchonly rejects on a network failure. A 404 or a 500 is a normal response, so checkres.okyourself.- Catching an error is not the same as handling it. Either recover with a sensible fallback or rethrow. Swallowing the error hides the bug.
Promise chains end with .catch
When you do not await a promise, finish the chain with .catch.
saveDraft(draft).then(showSaved).catch(showSaveError);
This is the fix for fire-and-forget calls such as analytics events or background saves.
Add a global safety net
A last line of defence tells you about the rejections you missed.
// Browser
window.addEventListener("unhandledrejection", (event) => {
reportError(event.reason);
});
// Node.js
process.on("unhandledRejection", (reason) => {
logger.fatal({ reason }, "Unhandled rejection");
process.exit(1);
});
Treat these as alarms. If they fire, there is a missing try/catch or .catch somewhere, and that is the real fix.
A short checklist
- Every
awaitthat can fail sits inside atryblock, or the caller handles it. - Every promise you do not await ends in
.catch. - HTTP responses are checked with
res.ok. - Errors are logged once, with context, in one place.
- The user sees what happened and what to do next.
When several requests run together and some may fail, Promise.allSettled keeps the successful results instead of throwing them away.