~/blog

fetch() resolves on a 500 — your catch block never runs

published

#javascript#fetch#http

TL;DR

fetch() rejects only when no HTTP response exists at all — DNS failure, connection refused, CORS block, aborted request. A 404, 500, or any other status is a fulfilled promise. If your error path is a catch around await fetch(...), server errors sail straight through it. Check response.ok (or response.status) before touching the body, every time.

The problem

This looks defensive, and isn’t:

try {
  const res = await fetch("/api/user/42");
  const user = await res.json();
  render(user);
} catch (err) {
  showError(err);
}

When the API returns 500 Internal Server Error with an HTML error page as the body, fetch resolves normally. The catch you do eventually see comes from the wrong place: res.json() throws SyntaxError: Unexpected token '<' because it tried to parse the HTML error page as JSON. The error you log blames the parser, the real failure was the status code, and if the failing endpoint happens to return valid JSON ({"error": "..."}), nothing throws anywhere — render() receives the error object as if it were a user.

Why it happens

The Fetch standard defines rejection as a network error, not an HTTP error. From the WHATWG spec’s own developer note: a fetch() promise rejects only on network failure or anything that prevented the request from completing — an HTTP response is the request completing, whatever its status. This was an intentional break from habits formed by libraries like Axios (which rejects on non-2xx by default). The browser’s job ended when it delivered a response; deciding whether 404 is exceptional is application logic.

The cases that genuinely reject:

CauseExample
DNS / connection failureserver down, wrong host
CORS rejectionmissing Access-Control-Allow-Origin
Mixed content / CSP blockhttp:// call from an https:// page
AbortAbortController.abort(), browser timeout
Body stream failure mid-readconnection dropped during res.json()

Note what that table implies: a CORS failure and a 503 look completely different to your code (TypeError: Failed to fetch vs a resolved response), even though both read as “the request didn’t work” from the user’s chair.

What to do

Gate on response.ok — true for status 200–299 — before reading the body:

async function getJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) {
    // Read the body as text for diagnostics — it may not be JSON.
    const body = await res.text().catch(() => "");
    throw new Error(`HTTP ${res.status} ${res.statusText} from ${url}: ${body.slice(0, 200)}`);
  }
  return res.json() as Promise<T>;
}

Two details worth keeping from that snippet: read failed bodies with .text(), never .json(), because error pages are frequently HTML even on JSON APIs; and include the status in the thrown error, because catch blocks downstream can’t recover it otherwise.

If you want Axios-style behavior everywhere, wrap once and use the wrapper — don’t sprinkle if (!res.ok) at some call sites and forget it at others. The forgotten ones are exactly where a deploy’s 502s will disappear into rendering code.

Caveats

References