Handling Async Errors in JavaScript Correctly: From What try/catch Misses to Promise.all vs allSettled
You wrapped it in try/catch and the error still escapes; the server returned 404 and fetch says it succeeded. The traps of async error handling: try/catch without await, return vs return await, fetch's res.ok, Promise.all vs allSettled, await in loops, and timeouts and cancellation, with a runnable demo.
You wrapped it in try/catch. Why does the error still escape?
You wrap a data-loading function in try/catch, but the error isn't caught.
async function load() {
throw new Error("Server error");
}
try {
load();
console.log("Got through the try block");
} catch (e) {
console.log("Caught", e.message); // never runs
}
Got through the try block
Uncaught (in promise) Error: Server error
An async function doesn't throw errors; it returns a rejected Promise. The try block finishes as soon as it receives the Promise, and the error surfaces later inside the Promise, so catch never gets a chance.
try {
await load(); // ✅ await turns the rejected Promise into a thrown error that reaches catch
} catch (e) {
console.log("Caught", e.message); // "Caught Server error"
}
To catch async errors with try/catch, you must await. await is the bridge that turns a rejected Promise into a thrown error.
Most async error handling falls apart in traps like this, where you think there's a safety net and there isn't. Let's go through them one by one.
Trap 1. return and return await are different
If you just return a Promise inside try, this function's catch won't run even when that Promise fails.
async function a1() {
try {
return load(); // ❌ hands the Promise straight through and the function ends
} catch (e) {
return "fallback";
}
}
async function a2() {
try {
return await load(); // ✅ waits here, so a failure lands in catch
} catch (e) {
return "fallback";
}
}
await a1(); // Error: Server error (catch is skipped)
await a2(); // "fallback"
When you return a Promise inside a try block, use return await. Outside of try, return and return await give the same result.
Trap 2. fetch succeeds on a 404
fetch only rejects when the network itself fails. If the server answers with 404 or 500, fetch treats it as success: "I got a response."
| Situation | fetch result | res.ok |
|---|---|---|
| 200 OK | Resolves | true |
| 404 Not Found | Resolves | false |
| 500 Server Error | Resolves | false |
| Offline, bad host, blocked by CORS | Rejects (TypeError) | - |
So you have to check the status yourself.
async function getJSON(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
return res.json();
}
Skip this check and you'll end up reading a 404 page's HTML with res.json(), getting a confusing SyntaxError that hides the real cause (the 404).
Trap 3. Promise.all fails as soon as one fails
Promise.all, which you use to send several requests at once, rejects immediately if any one of them fails. Even if the others succeed, you can't get their results.
| Method | Settles when | Result |
|---|---|---|
| Promise.all | All succeed, or immediately when one fails | Array of values, or the first error |
| Promise.allSettled | Waits until every one is done | { status, value / reason } for each |
| Promise.any | Any one succeeds | The first success (error if all fail) |
| Promise.race | Any one settles (success or failure) | Whichever settles first |
Press the buttons in the preview below. It sends three requests at once: A (0.6s), B (0.3s, fails), and C (0.9s).
Press Promise.all and it jumps to catch at 0.3s when B fails, but A and C keep running and finish afterward. Promise.all doesn't cancel anything; it just throws the results away.
Which one should you use?
| Situation | Use |
|---|---|
| You need all of it (product info + price + stock) | Promise.all |
| Show what you can even if some fail (several dashboard widgets) | Promise.allSettled |
| Use whichever of several servers answers first | Promise.any |
| Race against a timeout | Promise.race (these days AbortSignal.timeout is better) |
Trap 4. await inside loops
for...of + await waits for each one
// If each request takes 0.2s, this takes 0.6s total
for (const id of [1, 2, 3]) {
users.push(await getUser(id));
}
// Sent together, it takes 0.2s total
const users = await Promise.all([1, 2, 3].map(getUser));
If the requests don't depend on each other, sending them together with map + Promise.all is much faster. Use the "sequential" and "parallel" buttons in the demo above to see the difference. On the other hand, if each request needs the previous one's result, or you shouldn't hit the server all at once, sequential is the right call.
forEach + async doesn't wait
const result = [];
[1, 2, 3].forEach(async (id) => {
result.push(await getUser(id));
});
console.log(result); // [] ← moved on immediately without waiting
forEach ignores the Promise its callback returns. The loop ends immediately, and if something throws, the surrounding try/catch can't catch it.
Don't use await inside forEach. If you need to wait in order, use for...of; if they can run together, use map + Promise.all.
Trap 5. Requests that never finish: timeouts and cancellation
fetch has no default timeout. If the server never responds, the loading spinner spins forever. Set a limit with AbortSignal.timeout().
try {
const res = await fetch("/api/report", { signal: AbortSignal.timeout(5000) });
} catch (e) {
if (e.name === "TimeoutError") {
console.log("No response within 5 seconds");
}
}
When a previous request is no longer needed because the user left the page or changed the search term, cancel it yourself with AbortController.
let controller;
async function search(keyword) {
controller?.abort(); // cancel the previous search request
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${keyword}`, { signal: controller.signal });
return await res.json();
} catch (e) {
if (e.name === "AbortError") return; // a deliberate cancel isn't an error
throw e;
}
}
This also prevents the bug where a late response from an older search overwrites the latest results.
Where and how to handle errors
Rethrow without losing the cause: cause
When you catch a low-level error and turn it into a clearer one, put the original in cause so you can trace it when debugging.
try {
JSON.parse(text);
} catch (err) {
throw new Error("Couldn't read the config file", { cause: err });
}
// e.message → "Couldn't read the config file"
// e.cause → SyntaxError: ...
Don't swallow errors
// ❌ Catch an error and do nothing, and the fact that it failed disappears
try {
await save();
} catch (e) {}
A catch should do one of three things: tell the user, recover with a fallback, or rethrow. Where you can't handle an error, don't catch it. Let it bubble up to one place that can (the UI layer, the API client layer) and deal with it there.
The last safety net: unhandledrejection
Promise errors nobody caught can be received through a global event. Use it to send them to an error monitoring tool.
window.addEventListener("unhandledrejection", (event) => {
sendToMonitoring(event.reason); // send to a monitoring service (your own function)
});
In Node.js it's process.on("unhandledRejection", ...). This is strictly for recording errors you missed, not a replacement for handling them.
Summary: why this is worth knowing
| Trap | Correct handling |
|---|---|
| try/catch misses async errors | Add await |
| return promise inside try | return await promise |
| fetch resolves on 404/500 | if (!res.ok) throw ... |
| Promise.all loses everything when one fails | allSettled if partial failure is OK |
| for...of + await is slow | map + Promise.all for independent requests |
| forEach + async doesn't wait | for...of or Promise.all |
| Requests that never finish | AbortSignal.timeout(ms) |
| Requests you no longer need | Cancel with AbortController |
| Losing the cause when converting errors | new Error(msg, { cause }) |
Unlike synchronous errors, async errors disappear quietly. Small differences, one await, one res.ok line, for...of instead of forEach, separate "an error happened and nobody knew" from "the failure was reported and recovered from." When you write async code, ask each time: "If this Promise fails, where does the error go?"