Wrap a fetch() call in try/catch, hit an endpoint that answers with a 404, and the catch block never runs. The promise resolves. res.json() parses whatever body the server sent back, often an error payload like {"error": "user not found"}, and the rest of the function treats it as the record it asked for. No exception and no console warning. The bad data moves downstream until something else breaks on it.
| |
That catch only fires for a request that never completes. Everything else needs a check you write yourself.
Why does fetch() treat 404 and 500 as success?
fetch() rejects when the request itself fails: the DNS lookup fails, the connection drops mid-transfer, a CORS policy blocks the response from reaching your code, the user’s network goes away. In each of those cases no server answer ever reached you.
A 404 or a 500 is a server answering. The connection worked and a response came back with headers and a body. The exchange completed; it completed with bad news. The Fetch spec counts that as a successful fetch and leaves the status code for you to inspect. response.ok is true for any status in the 200â299 range and false for everything else, including 4xx/5xx responses and redirects fetch didn’t follow.
This is the detail that trips up anyone coming from Axios, which rejects on any non-2xx status by default. Neither behavior is wrong. The Fetch API rejects only for transport-level failures, on the reasoning that a 404 isn’t exceptional on the web. But if you expect Axios semantics from fetch(), you’ll ship the bug above.
How do you check response.ok and throw your own error?
One if, placed before you touch the body:
| |
That’s enough for a script. With more than one call site, repeating those lines everywhere is how one of them eventually gets skipped. Wrap it once:
| |
Every caller now gets a real exception on a bad status, with the status code and the server’s response body attached.
How do you read the error body when it isn’t JSON?
A 500 from your own API probably comes back as JSON. A 502 from a reverse proxy or a block page from a WAF usually comes back as HTML. Call res.json() on that and you get a SyntaxError (“Unexpected token ‘<’”) that has nothing to do with the real failure and sends you debugging the wrong thing.
fetchJSON above avoids this by reading the error body with res.text(), which doesn’t care whether the content is valid JSON. If you want the parsed object when it is JSON, check the content type first:
| |
A Response body can only be read once. If you need the raw text for logging and the parsed JSON for the caller, call res.clone() first and read one copy each. Reading the original twice throws “body stream already read.”
How do you tell a network failure from an HTTP error?
Once fetchJSON throws in both cases, a caller that wants to react differently has to check which one it got:
| |
A network failure surfaces as a plain TypeError with no status code, because there was never a response to read one from. The message varies by browser: “Failed to fetch” in Chrome, “NetworkError when attempting to fetch resource” in Firefox. So branch on the type, not the message.
One case looks like a network failure but isn’t. If a cross-origin request fails with that TypeError while the server’s logs show it arrived and answered 200, the browser blocked the response before your code saw it. The fix is in the server’s CORS headers, not in your client’s error handling; /en/posts/web/what-is-cors/ covers what those headers need to say.
How do you add a timeout to fetch()?
fetch() has no timeout. It waits as long as the connection stays open, so a backend that hangs instead of erroring holds the request indefinitely. AbortSignal.timeout() cuts it off:
| |
After 5 seconds the request aborts and the promise rejects with a TimeoutError, distinct from the AbortError you get when cancelling a request manually:
| |
AbortSignal.timeout() reached Baseline availability, meaning support across all current major browsers, in 2024. For an older browser or runtime, build the same thing from setTimeout and an AbortController:
| |
This fallback rejects with AbortError, not TimeoutError, so a check like the one above has to accept both names.
If the caller also needs to cancel for its own reasons (the user closed the dialog, a newer request superseded this one), combine both signals with AbortSignal.any([ownSignal, timeoutSignal]) rather than picking one. AbortSignal.any() is newer than timeout(), so confirm your target runtimes support it.
fetch vs Axios: which one throws on a 404?
fetch() | Axios | |
|---|---|---|
| 4xx / 5xx response | Resolves; check response.ok | Rejects; lands in catch |
| Response body | Call .json() / .text() yourself | Parsed automatically into response.data |
| Request timeout | None built in; use AbortSignal.timeout() | timeout config option |
| Network / CORS failure | Rejects with TypeError | Rejects with an AxiosError |
fetch() is built into every modern runtime, so there’s nothing to install. Axios gives you error-on-bad-status and automatic JSON parsing out of the box, at the cost of a dependency. If the codebase already pulls in Axios for interceptors or cancellation, use its defaults. If it doesn’t, the fetchJSON wrapper above gets you the same behavior in about twenty lines.
Which mistakes leave fetch errors unhandled?
- Checking
response.okafter calling.json(). By then the body has been consumed, and on an HTML error page the parse has already thrown. Checkokfirst, read the body second. - Calling an async function from an event handler without
awaitor.catch().onClick={() => fetchJSON(url)}drops the rejection: no catch block runs, and depending on the runtime you get an unhandled-rejection warning or nothing at all. /en/posts/javascript/javascript-async-await-event-loop/ explains where that rejected promise goes. - A
catchthat logs and returnsundefined.catch (err) { console.error(err); }swallows the error. The caller getsundefinedwhere it expected data and crashes somewhere else, further from the real cause. - Assuming every non-2xx body is JSON. This is the usual source of “my error handler threw a different error.” Read with
.text()or checkcontent-typefirst.
Where should the response.ok check live?
In one module. fetch() makes silence the default, so every call without an explicit status check reproduces the bug at the top of this article. Write fetchJSON and HttpError once, add the timeout and content-type handling from above, and make it the only way your app talks to a server. A lint rule or code-review habit that flags bare fetch() calls outside that module keeps it that way.