Knowledge Base/guides/How to Handle fetch Errors Properly in JavaScript

How to Handle fetch Errors Properly in JavaScript

fetch errors in JavaScript are split across two layers: request failures and HTTP responses. try/catch handles the first layer. response.ok and response.status handle the second. If you mix those up, a 404 or 500 can slide into your success path and make the rest of the code debug the wrong problem.

The mistake is assuming that HTTP status codes and JavaScript Promise rejection mean the same thing. They do not.

The naive mental model is too broad

The usual beginner model is reasonable:

try {
  const response = await fetch("/api/users/42");
  const user = await response.json();
  console.log(user.name);
} catch (error) {
  console.log("Could not load the user");
}

That code looks like it handles failure. It handles some failure.

If the user's Wi-Fi is gone, the DNS lookup fails, the URL is malformed, or the browser blocks the request before a response exists, the catch block can run.

If the server returns 404 Not Found, the catch block does not run. The server answered. The response is real. fetch() fulfilled its Promise with a Response object.

That means this can happen:

const response = await fetch("/api/users/42");

console.log(response.status); // 404
console.log(response.ok);     // false

const body = await response.json();
console.log(body); // { message: "User not found" }

From the browser's point of view, the request completed. From your application's point of view, the operation failed. Those are different layers.

What fetch actually promises

fetch() promises to start a request and give you a Response when a response is available. It does not promise that the server liked your request, found the resource, authenticated the user, or returned useful business data.

This is the useful split:

SituationDid a Response exist?Does fetch reject?
200 OKYesNo
201 CreatedYesNo
204 No ContentYesNo
404 Not FoundYesNo
500 Internal Server ErrorYesNo
Network offlineNoYes
Invalid URLNo valid requestYes
Request abortedNo completed responseYes

HTTP status codes live on the Response.

const response = await fetch("/api/users/42");

if (response.status === 404) {
  console.log("The server says this user does not exist.");
}

Promise rejection is reserved for cases where fetch() cannot complete the request/response exchange. A 404 is not that. It is an answer.

This is also why response.ok exists.

Bad responses are still responses

A bad HTTP response is still a response. That includes 400, 401, 403, 404, 409, 422, and 500. The server answered, so the fetch() Promise fulfilled with a Response object.

That is why the status check belongs immediately after the fetch:

const response = await fetch("/api/users/42");

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

const user = await response.json();

The throw is yours. You are deciding that a non-2xx status is an application error, which is usually the right contract for a JSON helper, page loader, or data-fetching function.

response.ok is the missing check

response.ok is a boolean shortcut for the status range 200 through 299.

const response = await fetch("/api/users/42");

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

const user = await response.json();

That throw is yours, not fetch's. You are converting an HTTP failure response into a JavaScript error because that is the contract you want for the rest of your code.

That distinction matters. It keeps you honest about where the failure came from.

async function loadUser(id: string) {
  const response = await fetch(`/api/users/${id}`);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.json();
}

Now callers can use try/catch and get the behavior they expected.

try {
  const user = await loadUser("42");
  console.log(user.name);
} catch (error) {
  console.log("Could not load the user");
}

The catch block runs for a network failure because fetch() rejected. It also runs for a 404 because your code looked at the Response and threw deliberately.

That is the pattern. Not magic. A boundary check.

The bug this causes in real UI code

The bad version usually does not explode immediately. That is why it is irritating.

type User = {
  id: string;
  name: string;
  email: string;
};

async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

If the server returns this with a 404:

{
  "message": "User not found"
}

TypeScript does not save you. The as Promise<User> shape is implied by your function signature, but runtime data still wins. Your UI receives an object that is not a User.

function UserHeader({ user }: { user: User }) {
  return <h1>{user.name}</h1>;
}

Now the heading renders empty text. Someone adds optional chaining. Then a fallback. Then another fallback three components down. The original bug was at the network boundary, but now it has spread through the UI like spilled coffee on a desk.

Check the response before parsing the success path.

async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);

  if (!response.ok) {
    throw new Error(`Failed to load user ${id}: HTTP ${response.status}`);
  }

  return response.json();
}

Now your rendering code receives a User or it does not run. Much better. Still not glamorous. Frontend work rarely is, despite what component library homepages keep implying.

Build a small fetchJson wrapper

Writing the same response.ok check in every component gets old, and old code gets skipped.

Use a tiny wrapper.

type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonValue[]
  | { [key: string]: JsonValue };

export async function fetchJson<T extends JsonValue>(
  input: RequestInfo | URL,
  init?: RequestInit,
): Promise<T> {
  const response = await fetch(input, init);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
  }

  return response.json() as Promise<T>;
}

Then application code can be boring.

type User = {
  id: string;
  name: string;
  email: string;
};

const user = await fetchJson<User>("/api/users/42");

The wrapper gives your app a simpler rule:

  • If the request cannot complete, throw.
  • If the server responds with a non-2xx status, throw.
  • If the server responds with a 2xx status, parse JSON.

That is not the only valid contract. It is the one many apps actually want.

JSON parsing order matters

This version looks harmless:

const response = await fetch("/api/users/42");
const data = await response.json();

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

return data;

It is backwards.

The error body may not be JSON. It may be HTML from a framework error page. It may be empty. It may have a different shape from success data. Calling response.json() first means a parse error can hide the actual HTTP status you needed.

Prefer this order:

const response = await fetch("/api/users/42");

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

return response.json();

There are exceptions. Some APIs return useful structured error bodies, and you may want to read them. Fine. Do it inside the error path, and keep it deliberate.

async function readErrorMessage(response: Response): Promise<string> {
  try {
    const body = (await response.json()) as { message?: unknown };
    return typeof body.message === "string" ? body.message : response.statusText;
  } catch {
    return response.statusText;
  }
}

async function fetchJsonWithMessage<T extends JsonValue>(
  url: string,
): Promise<T> {
  const response = await fetch(url);

  if (!response.ok) {
    const message = await readErrorMessage(response);
    throw new Error(`HTTP ${response.status}: ${message}`);
  }

  return response.json() as Promise<T>;
}

That is a good pattern when your API consistently returns JSON errors. It is overkill for every small example, but production code has a way of becoming less small while nobody is looking.

404 is not always exceptional

One more wrinkle: you do not always want to throw on 404.

For a detail page, a missing user might be an error state:

const user = await fetchJson<User>("/api/users/42");

For an availability check, a missing record may be a normal answer:

async function usernameIsAvailable(username: string): Promise<boolean> {
  const response = await fetch(`/api/usernames/${username}`);

  if (response.status === 404) {
    return true;
  }

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return false;
}

This is the real reason fetch() does not decide for you. HTTP status is protocol information. Your application decides what that information means.

A 404 on /api/users/42 may mean "show not found". A 404 on /api/feature-flags/new-checkout may mean "use the default config". Same status, different product meaning.

How this fits with the rest of your fetch code

If you are still learning the API shape, start with the core lesson on The Fetch API. The important bit is that fetch() gives you a Response, and the body is read separately with methods like .json().

When you are ready for the failure paths, the next step is Fetch Error Handling and HTTP Status Codes. That lesson goes deeper on response.ok, status-specific handling, and the difference between network errors and HTTP responses.

The short version:

const response = await fetch(url);

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

const data = await response.json();

Write that enough times to understand it. Then put it in a wrapper so you stop writing it.

Key Takeaways

  • fetch() does not throw on 404 because a 404 is still a completed HTTP response.
  • catch handles request failures. response.ok handles HTTP success or failure.
  • Throwing on !response.ok is not required by fetch. It is required by your application contract.
  • Check the status before parsing success data unless you intentionally handle structured error bodies.
  • Do not scatter this logic across components. Put the policy in a small fetch wrapper and make the rest of the app read like it means it.
Share this article:
javascriptfetcherrorsasync