skip to content

A fetch() call receives an HTTP 500 response from the server, yet the .catch() handler never runs and the .then() branch executes instead. Why does the Fetch API behave this way, and how do you detect the failure?

level: juniorimportance: must knowfreq 85%

answer

  1. a response is still a response
  2. rejection means no response arrived
  3. the ok flag, not the promise
  4. throw it yourself before parsing
  5. TypeError versus HTTP status

basics

~20 s

fetch() resolves for any completed HTTP exchange, including 404 and 500, and rejects only when no response arrives at all — a network or DNS failure, or a blocked request. Detect HTTP failures yourself with response.ok or response.status.

solid answer

~40 s

The Fetch Standard treats "the server answered" as success, whatever it answered. A 500 is a perfectly well-formed HTTP response, so the promise fulfils with a `Response` object whose `status` is 500 and whose `ok` is `false`. `fetch()` rejects only when there is no response to hand back: DNS failure, connection refused or reset, a TLS error, a request blocked by CORS or Content-Security-Policy, a malformed URL, or an abort. Those reject with a `TypeError` (an `AbortError` `DOMException` for aborts) carrying deliberately little detail, because leaking why a cross-origin request failed would expose information about another origin. So every real wrapper checks `if (!response.ok) throw …` before touching the body — otherwise a 500 whose body is an HTML error page silently reaches `response.json()`, which then rejects with a confusing parse error.

go deeper

for a junior

Be able to state plainly that fetch only rejects when no response came back, and show the if (!response.ok) throw … guard before parsing. Knowing ok means status 200–299 is expected.

for a middle

Explain which conditions actually reject — DNS, connection, TLS, CORS or CSP block, bad arguments, abort — and that they surface as a TypeError, with abort as an AbortError instead. Say why the error is deliberately vague.

for a senior

Show the wrapper you would ship: read the body before throwing, distinguish transport failure from an application-level status, and handle bodyless 204 responses. Explain why conflating the two failure kinds produces misleading incident reports.

for a principal

Own the contract across the codebase: whether non-2xx becomes a thrown error or a returned result type, how status and server payload travel to logging and telemetry, and how that choice interacts with retry policy for genuinely retryable transport failures.

## The rule in one line `fetch()` rejects only when the browser could not obtain a response at all. Anything the server actually said — 200, 301, 404, 500 — is a *successful* outcome for the promise. The HTTP status is data about the exchange, not an error in the exchange. ## Why the API was designed this way A `Response` object models a completed HTTP message: a status line, headers, and a body. When a server replies "500 Internal Server Error", the request was sent, the connection worked, and a full response came back. From the transport's point of view nothing failed, so the Fetch Standard fulfils the promise and hands you the `Response` to inspect. Rejection is reserved for the cases where there is genuinely nothing to inspect: - DNS resolution failed, or the connection was refused, reset, or timed out at the transport level - the TLS handshake failed - the browser refused to make or expose the request: a CORS check failed, Content-Security-Policy blocked it, or mixed content was blocked - the arguments were invalid: a malformed URL, an illegal method such as `CONNECT`, or a forbidden header - the request was aborted through an `AbortSignal` All of these reject with a `TypeError`, except abort, which rejects with an `AbortError` `DOMException`. The `TypeError` is intentionally vague — it does not tell you whether the host was unreachable or the CORS check failed, because that difference would let a page probe another origin's network state. ## Reading the outcome correctly The `Response` gives you everything you need: - `response.ok` — `true` exactly when `status` is in the 200–299 range - `response.status` and `response.statusText` - `response.headers`, a `Headers` object - `response.redirected` — whether the browser followed one or more redirects to get here - `response.type` — `"basic"`, `"cors"`, `"opaque"`, or `"opaqueredirect"` A correct wrapper converts a non-`ok` response into a thrown error itself, and does so *before* parsing: ```js async function getJson(url, init) { const response = await fetch(url, init); if (!response.ok) { const detail = await response.text(); throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail.slice(0, 200)}`); } return response.json(); } ``` Note the order: read the body as text *first*, so the server's error message survives into the thrown error, then throw. Throwing before reading the body discards the most useful diagnostic you had. ## The second trap: the parse error that looks like a network error Skip the `ok` check and a 500 that returns an HTML error page flows straight into `response.json()`. That call rejects with a `SyntaxError` about an unexpected `<`. Your `catch` block fires, so it *feels* like fetch rejected on the 500 — and people conclude fetch does reject on 5xx. It did not; the parse did. The distinction matters the moment you try to log the status, because by then you have thrown away the `Response`. ## Empty bodies `ok` is also `true` for 204 No Content, which by definition has no body. `response.json()` on an empty body rejects with a parse error, so a wrapper that always calls `json()` breaks on the first endpoint that returns 204. Guard on the status, or read `text()` and only parse when the string is non-empty. ## Comparing with what people expect Most HTTP client libraries built on top of the platform reject on non-2xx by default, which is why the fetch behaviour surprises people arriving from one. Neither choice is wrong; fetch is simply lower-level, and the layer that decides "a 404 is an error for my application" is your code. Some APIs legitimately use 404 as a normal answer ("no such record yet") or 409 as an expected outcome, and a client that rejects on every non-2xx forces you to unwrap errors to find them. ## What to do in practice Write one helper, use it everywhere, and make it distinguish three outcomes: promise rejection (no response — retry or report offline), a non-`ok` response (an application-level failure with a status and a body worth surfacing), and success. Conflating the first two is how "the server is down" ends up in a user's face when the real answer was 403.

  • What exactly does fetch reject with, and how much can you learn from that error?
    Network-level failures reject with a `TypeError`; an aborted request rejects with an `AbortError` `DOMException`. The `TypeError` is deliberately uninformative — it does not distinguish a refused connection from a CORS block, because telling a page why a cross-origin request failed would leak the state of another origin. If you need to tell them apart, you need server-side or devtools evidence, not the error object.
  • Why should a wrapper read the response body before throwing on a non-ok status?
    Because the body usually carries the server's explanation — a JSON problem document, a validation list, a stack trace in dev. Once you throw, the `Response` is gone and the body can never be read. Reading `await response.text()` first, then throwing with the status and a truncated body attached, keeps the only diagnostic the server gave you.
  • A wrapper checks response.ok and then calls response.json() on every request. Where does that break?
    On any response with no body. A 204 No Content passes the `ok` check because 204 is in the 2xx range, but `json()` on an empty body rejects with a parse error. Guard it: return `null` for 204, or read `text()` and only `JSON.parse` when the string is non-empty.

saying these in an interview costs you the question

  • Says fetch rejects the promise on 4xx or 5xx statuses
  • Wraps fetch in try/catch and assumes anything after it succeeded
  • Treats a caught error as proof the network failed
  • Checks status === 200 only, missing 201 and 204
  • Throws on a bad status without first reading the body

context