fetch() resolves on a 500 — your catch block never runs
published
TL;DR
fetch() rejects only when no HTTP response exists at all — DNS failure, connection refused, CORS block, aborted request. A 404, 500, or any other status is a fulfilled promise. If your error path is a catch around await fetch(...), server errors sail straight through it. Check response.ok (or response.status) before touching the body, every time.
The problem
This looks defensive, and isn’t:
try {
const res = await fetch("/api/user/42");
const user = await res.json();
render(user);
} catch (err) {
showError(err);
}
When the API returns 500 Internal Server Error with an HTML error page as the body, fetch resolves normally. The catch you do eventually see comes from the wrong place: res.json() throws SyntaxError: Unexpected token '<' because it tried to parse the HTML error page as JSON. The error you log blames the parser, the real failure was the status code, and if the failing endpoint happens to return valid JSON ({"error": "..."}), nothing throws anywhere — render() receives the error object as if it were a user.
Why it happens
The Fetch standard defines rejection as a network error, not an HTTP error. From the WHATWG spec’s own developer note: a fetch() promise rejects only on network failure or anything that prevented the request from completing — an HTTP response is the request completing, whatever its status. This was an intentional break from habits formed by libraries like Axios (which rejects on non-2xx by default). The browser’s job ended when it delivered a response; deciding whether 404 is exceptional is application logic.
The cases that genuinely reject:
| Cause | Example |
|---|---|
| DNS / connection failure | server down, wrong host |
| CORS rejection | missing Access-Control-Allow-Origin |
| Mixed content / CSP block | http:// call from an https:// page |
| Abort | AbortController.abort(), browser timeout |
| Body stream failure mid-read | connection dropped during res.json() |
Note what that table implies: a CORS failure and a 503 look completely different to your code (TypeError: Failed to fetch vs a resolved response), even though both read as “the request didn’t work” from the user’s chair.
What to do
Gate on response.ok — true for status 200–299 — before reading the body:
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
// Read the body as text for diagnostics — it may not be JSON.
const body = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText} from ${url}: ${body.slice(0, 200)}`);
}
return res.json() as Promise<T>;
}
Two details worth keeping from that snippet: read failed bodies with .text(), never .json(), because error pages are frequently HTML even on JSON APIs; and include the status in the thrown error, because catch blocks downstream can’t recover it otherwise.
If you want Axios-style behavior everywhere, wrap once and use the wrapper — don’t sprinkle if (!res.ok) at some call sites and forget it at others. The forgotten ones are exactly where a deploy’s 502s will disappear into rendering code.
Caveats
response.okis strictly 2xx. A304 Not Modifiedis not “ok”, which is correct for directfetchcalls (the browser handles 304 transparently for cached requests — you’ll normally see the 200 with a cached body instead).- Redirects are followed automatically by default; you see the final response’s status, not the
301. Opt out withredirect: "manual"if the redirect itself is the signal. AbortControllerrejections arrive asDOMExceptionnamedAbortError— if you wrap fetch, decide whether user-cancelled counts as an error before you log it as one.- Node’s built-in
fetch(undici) follows the same spec: no rejection on HTTP errors. Code migrated from Axios to native fetch needs theokcheck added, not just the import swapped.