Skip to Content
fetch With Proper Error Handling (It Doesn't Throw on 404)
Intermediate
0 likes
Tutorials

fetch With Proper Error Handling (It Doesn't Throw on 404)

fetch only rejects when there's no response. A 404 or 500 resolves normally, so check response.ok, add a timeout, and keep the status on the error so the caller can decide what to tell the user.

fetch only rejects when there's no response at all: the network is down, the server can't be reached, the request was blocked or cancelled. A 404 or a 500 is still a response, so fetch resolves normally and your code carries on with an error page as if it were data. You have to check response.ok yourself. This page builds one small helper that handles all four ways a request can fail, and shows what each looks like.

Skip ahead: Just want the code? Skip to "The helper".

The four ways a request fails

  1. HTTP error: the server answered with 4xx or 5xx. fetch resolves. response.ok is false.
  2. Network error: no answer at all (offline, DNS failure, connection refused, CORS blocked in the browser). fetch rejects with a TypeError. In Node the message is fetch failed. In Chrome it's Failed to fetch.
  3. Timeout: the server is too slow. fetch has no default timeout, so without one, it can wait for a very long time. You add one with an AbortSignal.
  4. Bad body: status 200, but the body isn't the JSON you expected. response.json() rejects with a SyntaxError.

The 404 that doesn't throw

const response = await fetch("https://jsonplaceholder.typicode.com/users/999");
console.log(response.ok, response.status);
const user = await response.json();
console.log(user, user.name);
false 404
{} undefined

No exception. If this was a profile page, it would now say "Hello, undefined".

The helper

class HttpError extends Error {
  constructor(response, body) {
    super(`HTTP ${response.status} ${response.statusText} for ${response.url}`);
    this.name = "HttpError";
    this.status = response.status;
    this.body = body;
  }
}

async function fetchJson(url, { timeoutMs = 8000, ...options } = {}) {
  const response = await fetch(url, { ...options, signal: AbortSignal.timeout(timeoutMs) });
  if (!response.ok) {
    const body = await response.text().catch(() => "");
    throw new HttpError(response, body.slice(0, 200));
  }
  return response.json();
}

const user = await fetchJson("https://jsonplaceholder.typicode.com/users/1");
console.log("ok:", user.name);

for (const [label, url, opts] of [
  ["404", "https://jsonplaceholder.typicode.com/users/999"],
  ["timeout", "https://jsonplaceholder.typicode.com/users/1", { timeoutMs: 1 }],
  ["network", "http://127.0.0.1:9/"],
]) {
  try {
    await fetchJson(url, opts);
  } catch (error) {
    console.log(`${label}: ${error.name}: ${error.message}`);
  }
}
ok: Leanne Graham
404: HttpError: HTTP 404 Not Found for https://jsonplaceholder.typicode.com/users/999
timeout: TimeoutError: The operation was aborted due to timeout
network: TypeError: fetch failed

What each part does:

  • if (!response.ok) turns 4xx and 5xx into a real error, so it goes to your catch like any other failure.
  • HttpError keeps the status, so the caller can treat a 404 ("not found") differently from a 401 ("log in again") or a 503 ("try again later"). It also keeps the first part of the body, which often contains the server's error message.
  • AbortSignal.timeout(timeoutMs) cancels the request if it takes too long. It rejects with a TimeoutError. It's supported in current browsers and in Node 17.3 and later.
  • response.json() at the end still throws a SyntaxError if the body isn't JSON. Let it go to the caller, because there's nothing sensible to return.

⚠ Warning: Don't retry everything. A 400 or 404 will fail again exactly the same way. Retry only network errors, timeouts, 429 (too many requests) and 5xx, a small number of times, with a growing wait between tries. And never automatically retry a request that creates something, like placing an order, unless the API supports it safely. Two retries can mean three orders.

Using it

try {
  const user = await fetchJson(`/api/users/${id}`);
  showUser(user);
} catch (error) {
  if (error.name === "HttpError" && error.status === 404) {
    showMessage("That user doesn't exist.");
  } else if (error.name === "HttpError" && error.status === 401) {
    redirectToLogin();
  } else if (error.name === "TimeoutError") {
    showMessage("The server is slow right now. Try again in a minute.");
  } else {
    showMessage("Couldn't load the user. Check your connection.");
    console.error(error);
  }
}

The user gets a message that matches what happened, and unexpected errors still reach the console.

What it looks like in DevTools

In the browser, a failed request is red in the Network tab even when your code didn't throw:

Chrome with DevTools docked at the bottom, on the Network tab. The page says Could not load user 999: HTTP 404 in red. In the request list, the row named 999 is red and boxed in red: status 404, type fetch, initiator main.js:2.

Status 404 or 500 means the server answered: use response.ok. Status shown as (failed) or (canceled) means there was no response: that's the TypeError or the timeout. If the console also says something about CORS, the server didn't allow your page's origin, and that has to be fixed on the server, not in fetch.

💡 Tip: response.status 0 with type: "opaque" means you used mode: "no-cors". That doesn't fix CORS. It just hides the response from your code. Remove it.

Checklist

  1. Check response.ok on every request.
  2. Add a timeout.
  3. Keep the status on the error so callers can decide.
  4. Show the user a message that matches the failure.
  5. Log the unexpected ones.

Turn off your Wi-Fi and use your app. What does the user see?

About

Build a small fetchJson helper that handles HTTP errors, network errors, timeouts and bad JSON, then show the right message for each. After this, a failed request never turns into "Hello, undefined".

Responsible
Christopher R
Last Update
09/03/2026
Reading Time
5 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.