· 6 min read
Why fetch() doesn't reject on 404 or 500, and how to fix it
JavaScript fetch() resolves on 404 and 500 errors, so your catch block never runs. Here's why, and a small wrapper that checks response.ok properly.

fetch() does not reject when the server returns 404 or 500. The promise resolves with a Response object as soon as headers arrive, whatever the status code, and only rejects when no response arrives at all: a network failure, a CORS block, an invalid URL or an abort. To treat HTTP errors as errors, check response.ok and throw yourself.
That one rule explains a whole family of bugs: catch blocks that never run, "Unexpected token '<'" errors in the console, and success toasts shown for failed saves.
Reproduce it
Here is the code that looks correct and is not:
try {
const res = await fetch('/api/users/42');
const user = await res.json();
render(user);
} catch (err) {
showError('Could not load user');
}
If /api/users/42 returns a 404 with an HTML error page, the catch still runs, but for the wrong reason. fetch resolved fine. It was res.json() that threw, because it tried to parse HTML:
SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON
Now change the server so the 404 or 500 comes back as JSON, like {"error":"db down"}. Parsing succeeds, the catch never runs, and render() receives an error object as if it were a user. No exception, no log, just a broken screen. That is the version that reaches production, because it fails quietly.
Why doesn't fetch reject on 404?
From the protocol's point of view, a 404 is a successful HTTP exchange. The request went out, the server answered, and the answer was "not found". The Fetch standard treats that as a response, and a promise for a response should resolve when one exists. Rejection is reserved for cases where there is no response to hand back.
The older XMLHttpRequest API behaved the same way, so this was not a new choice. It feels surprising mostly because libraries such as Axios reject on non-2xx statuses by default, and many people learned HTTP clients through them.
Here is what each case actually does, checked in Node 22, which uses the same standard:
| Situation | Promise | What you get |
|---|---|---|
| 200 OK | resolves | res.ok === true |
| 404, 500, any 4xx/5xx | resolves | res.ok === false, res.status set |
| 204 No Content | resolves | res.ok === true, but res.json() throws |
| Server unreachable | rejects | TypeError |
| Invalid URL | rejects | TypeError |
AbortController.abort() | rejects | AbortError |
AbortSignal.timeout() fires | rejects | TimeoutError |
What does response.ok actually check?
response.ok is a boolean that is true only when the status is between 200 and 299 inclusive. Every other status counts as not ok.
Redirects are worth a note. By default fetch follows them, so a 301 to a working page resolves with the final 200 response, and res.redirected tells you it happened. With redirect: 'manual', Node hands you the 3xx response itself, while browsers return an opaque response with status 0, so neither counts as ok.
Network failures carry less detail than you might hope. In Node, the rejection is a TypeError with the message fetch failed, and the real reason sits on err.cause, for example cause.code === 'ECONNREFUSED'. In browsers the message varies by engine and gives away little, partly so a page cannot probe which hosts exist on your network. A CORS failure, where the browser blocks the response because the server did not allow your origin, also shows up as a plain TypeError with no status. Check the console for the real reason; your code cannot see it.
The fix: check response.ok and throw
Do the check once in a small wrapper, then use the wrapper everywhere instead of calling fetch directly:
class HttpError extends Error {
constructor(response, body) {
super(`HTTP ${response.status} ${response.statusText} for ${response.url}`);
this.name = 'HttpError';
this.status = response.status;
this.body = body;
}
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, {
signal: AbortSignal.timeout(10_000),
...options,
headers: { Accept: 'application/json', ...options.headers },
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new HttpError(response, body);
}
if (response.status === 204) return null;
return response.json();
}
A few details in there are deliberate:
- Read the error body as text, not JSON. Error pages from proxies, CDNs and load balancers are often HTML even when your API speaks JSON. Reading text first means the error you throw is about the status, not a confusing parse failure. Parse it afterwards if you need fields from it.
- Keep the status on the error. Callers usually want to branch on it: redirect to login on 401, show "not found" on 404, retry on 503.
- Handle 204. A successful response with no body makes
res.json()throwUnexpected end of JSON input. Returningnullkeeps the happy path clean. - Add a timeout.
fetchhas no default timeout of its own, so a hung server can leave the promise pending for as long as the underlying connection allows.AbortSignal.timeout(ms)rejects with aTimeoutErrorafter that time. A caller passing its ownsignalreplaces it, because of the spread order.
Calling code now reads the way the original code hoped it would:
try {
const user = await fetchJson('/api/users/42');
render(user);
} catch (err) {
if (err.name === 'HttpError' && err.status === 404) showNotFound();
else if (err.name === 'TimeoutError') showError('The server took too long');
else showError('Could not load user');
}
How to tell the failure types apart
Once the wrapper is in place, you have three distinct kinds of error, and they deserve different handling:
HttpError: the server answered with a failure status. Retrying makes sense for 502, 503 and 504, rarely for 4xx.TimeoutErrororAbortError: you gave up, or something cancelled the request. AnAbortErrorfrom a component unmounting is usually not worth showing to the user at all.TypeError: no response at all. The user may be offline, DNS may have failed, or CORS blocked the request. A retry with backoff, or an offline message, fits here.
Mixing these into one generic "something went wrong" hides the information you need when debugging, and it shows users a retry button for requests that will never succeed.
One more case belongs on the list even though fetch handled it correctly: a 200 response whose body is not what you expect. A login page served with status 200 by a misconfigured proxy will pass the ok check and fail at json(). That SyntaxError is real information, so let it propagate rather than swallowing it.
Key takeaways
fetchresolves for every HTTP status, including 404 and 500. It rejects only when there is no response.- Check
response.ok(status 200 to 299) and throw your own error when it is false. - Read error bodies as text first; error pages are often HTML even on JSON APIs.
- Handle 204 before calling
json(), and addAbortSignal.timeout()becausefetchhas no timeout of its own. - Keep network errors, timeouts and HTTP errors as separate types so you can handle each properly.
FAQ
Does Axios behave the same way?
No. Axios rejects for any status outside 2xx by default, and you can change that with its validateStatus option. Code moved from Axios to fetch loses that check unless you add it back.
Why does res.json() say "Unexpected token '<'"?
The response body is HTML, usually an error page or a fallback index.html, and the parser stops at the first <. Check res.status and the Content-Type header; the request almost always failed before the parse did.
Is a CORS error catchable?
Yes, the promise rejects with a TypeError, so catch runs. Your code cannot read the reason or the status, though. The browser reports the details only in the developer console.