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.

1
2
3
4
5
6
7
8
9
async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    const user = await res.json();
    return user; // on a 404 this is { error: "user not found" }
  } catch (err) {
    console.error('Failed to load user:', err);
  }
}

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:

1
2
3
4
5
6
7
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    throw new Error(`GET /api/users/${id} failed with ${res.status}`);
  }
  return res.json();
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class HttpError extends Error {
  constructor(response, body) {
    super(`${response.status} ${response.statusText}`);
    this.name = 'HttpError';
    this.status = response.status;
    this.body = body;
  }
}

async function fetchJSON(url, options) {
  const res = await fetch(url, options);
  if (!res.ok) {
    const body = await res.text().catch(() => '');
    throw new HttpError(res, body);
  }
  return res.json();
}

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:

1
2
3
4
5
6
7
if (!res.ok) {
  const contentType = res.headers.get('content-type') ?? '';
  const body = contentType.includes('application/json')
    ? await res.json().catch(() => null)
    : await res.text();
  throw new HttpError(res, body);
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
try {
  const user = await fetchJSON(`/api/users/${id}`);
  render(user);
} catch (err) {
  if (err instanceof HttpError && err.status === 404) {
    showNotFound();
  } else {
    showConnectionError(); // DNS failure, dropped connection, CORS block
  }
}

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:

1
2
3
4
5
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const res = await fetch(url, { ...options, signal: AbortSignal.timeout(timeoutMs) });
  if (!res.ok) throw new HttpError(res, await res.text());
  return res.json();
}

After 5 seconds the request aborts and the promise rejects with a TimeoutError, distinct from the AbortError you get when cancelling a request manually:

1
2
3
4
5
6
7
try {
  await fetchWithTimeout('/api/slow-report');
} catch (err) {
  if (err.name === 'TimeoutError') {
    // the request was still in flight at 5s
  }
}

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:

1
2
3
4
5
function timeoutSignal(ms) {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), ms);
  return controller.signal;
}

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 responseResolves; check response.okRejects; lands in catch
Response bodyCall .json() / .text() yourselfParsed automatically into response.data
Request timeoutNone built in; use AbortSignal.timeout()timeout config option
Network / CORS failureRejects with TypeErrorRejects 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.ok after calling .json(). By then the body has been consumed, and on an HTML error page the parse has already thrown. Check ok first, read the body second.
  • Calling an async function from an event handler without await or .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 catch that logs and returns undefined. catch (err) { console.error(err); } swallows the error. The caller gets undefined where 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 check content-type first.

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.