Skip to main content

The fetch API's Silent Failures

Updated 6 min readMarkdown

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

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:

// 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:

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:

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

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

2 views

No comments yet. Be the first to comment.