# The fetch API's Silent Failures

There is a bug I have written more than once, and I bet you have too:

```js
const res = await fetch("/api/user/123");
const user = await res.json();
render(user);
```

It works in development, ships, and then one day the API returns a 404 page — and the error that lands in your logs is `SyntaxError: Unexpected token '<'`, pointing at the `res.json()` line. The fetch didn't fail. The JSON parse failed, three lines away from the actual problem, with a message that says nothing about a missing user. The bug is a misunderstanding of what `fetch` promises. It promises far less than it looks like it does.

`fetch()` rejects its promise only when the request can't be made at all — no network, DNS failure, connection refused, aborted. An HTTP error status is not a failure as far as fetch is concerned. The server answered, so the promise resolves. A 404, a 500, a redirect — all of them resolve. To prove it instead of asserting it, I ran a small Node script on this machine (Node 24) that starts a local server and makes five fetch calls against it:

```js
// fetch-silent-failures.mjs — node fetch-silent-failures.mjs
import http from "node:http";

const server = http.createServer((req, res) => {
  if (req.url === "/missing") {
    res.writeHead(404, { "content-type": "text/html" });
    res.end("<h1>404 — nothing here</h1>");
  } else if (req.url === "/boom") {
    res.writeHead(500, { "content-type": "text/plain" });
    res.end("database is down");
  } else if (req.url === "/moved") {
    res.writeHead(301, { location: "/ok" });
    res.end();
  } else if (req.url === "/slow") {
    setTimeout(() => {
      res.writeHead(200, { "content-type": "text/plain" });
      res.end("finally");
    }, 3000);
  } else {
    res.writeHead(200, { "content-type": "text/plain" });
    res.end("hello");
  }
});

await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${server.address().port}`;
```

Real output, verbatim:

```plaintext
case 1 — 404: status = 404 | ok = false
case 2 — 500: status = 500 | ok = false
case 3 — 301: status = 200 | redirected = true | final url = http://127.0.0.1:PORT/ok
case 4 — refused: THREW: TypeError — fetch failed
case 5 — abort: THREW: AbortError — This operation was aborted
classic bug — .json() on the 404 page THREW: SyntaxError — Unexpected token '<', "<h1>404 — "... is not valid JSON
```

## Case 1 and 2: the 404 and the 500 that don't throw

Both error statuses resolved normally. The only difference from a successful call is the `Response` object: `status` is 404 or 500, and `ok` — true for 200–299 — is `false`. Nothing else tells you something went wrong. The response body is still there to read; the 404 page is a full HTML document, the 500 is the plain text "database is down." fetch hands you the error page and considers its job done.

This is the design, not a bug in fetch. HTTP separates transport from meaning: the request succeeded (a response arrived), and the status code carries the meaning. But it means a bare `await fetch(...)` in a try/catch catches nothing when the API is down in the HTTP sense. The server is on fire, your catch block is asleep.

## Case 3: the redirect you never see

The `/moved` endpoint answers 301 to `/ok`. fetch followed it without asking, and the final response reports `status = 200` with `redirected = true`. No throw, no warning, no log. Most of the time that's what you want — but the URL you requested is not the URL that answered, and if the redirect crossed to a different origin, your `Authorization` header did not follow it (fetch strips it on cross-origin redirects). Silent redirect-following is convenient until it isn't, usually in auth flows. If you want to see redirects instead of following them, pass `redirect: "manual"` and read the `Location` header yourself.

## Case 4 and 5: the two that actually throw

Fetch to a port with nothing listening — `http://127.0.0.1:9/nope` — threw `TypeError: fetch failed`. That's the network-level failure fetch is built to report: no TCP connection, no response, nothing to resolve with. Note how unhelpful the message is: "fetch failed" doesn't say connection refused. The underlying cause is attached as `err.cause` in Node; log that too or you'll be guessing.

The abort case threw `AbortError: This operation was aborted`. The request was fine, the server was fine, the client gave up after 100 ms against a 3-second endpoint. A timeout is a client decision, so it surfaces as a rejection — but it's a rejection with a different name, and your catch block needs to know which one it got. A network error means "try again later"; an abort means "you gave up," and retrying immediately just repeats the timeout.

## The classic bug, reproduced

The last line of the output is the one from the opening:

```plaintext
classic bug — .json() on the 404 page THREW: SyntaxError — Unexpected token '<', "<h1>404 — "... is not valid JSON
```

The fetch resolved. The `.json()` call threw, because the 404 page is HTML and `<h1>` is not valid JSON. The error names a parse problem, not a missing user, and it points at the wrong line. This is the failure mode that eats debugging time: the symptom and the cause are in different places, joined only by an unchecked response.

## The fix is one check

```js
const res = await fetch("/api/user/123");
if (!res.ok) {
  throw new Error(`Request failed: ${res.status} ${res.statusText}`);
}
const user = await res.json();
```

That's the whole pattern. Check `res.ok` before you touch the body, and throw with the status in the message so the error says what happened. For timeouts, use `AbortSignal.timeout(5000)` instead of a hand-rolled controller — one line, same `AbortError`, and the signal's `reason` tells you it was a timeout. And when you catch, separate the cases: a `TypeError` means the network failed, an `AbortError` means you gave up, and an HTTP status means the server answered and said no.

The rule to carry away: fetch tells you whether the conversation happened, not whether it went well. The status code is the answer to the second question, and nobody checks it for you.

---

*P.S. Everything I write stays free — that part doesn't change. But if this one was worth something to you, you can buy me a coffee ☕ (one-time or monthly, both work). It covers the API credits and hosting behind these experiments — and honestly, it's also the nudge that gets the next one started on the evenings I'd otherwise skip it. — Salam*

[![Buy me a coffee](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/87bycvv0drlpbwxjppyi.png)](https://www.patreon.com/c/AheadKit)
