DEV Community

Gursimar Singh
Gursimar Singh

Posted on AI-assisted

Why Your JavaScript fetch() Doesn’t Catch a 404—and How to Fix It

You build a page that loads data from an API. You wrap the request in try...catch, add an error message, and expect failed requests to reach the catch block.

Then your API returns 404, and the error message never appears.

The reason: fetch() does not reject its promise just because the server returns an HTTP error status.

A server can successfully send back a response that says the request failed. Your code needs to check that response.

Here’s how to handle it without turning a small request into a complicated abstraction.

The easy-to-miss mistake

Imagine a course listing page that requests data from your backend:

async function loadCourses() {
  try {
    const response = await fetch("/api/courses");
    const courses = await response.json();

    console.log(courses);
  } catch (error) {
    console.error("Could not load courses:", error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Suppose the server returns:

HTTP/1.1 404 Not Found
Content-Type: application/json
Enter fullscreen mode Exit fullscreen mode

With this body:

{
  "message": "Route not found"
}
Enter fullscreen mode Exit fullscreen mode

The request returns a response, and the body contains valid JSON. Neither operation has to throw.

Your courses variable now contains an error object.

If the next part of your application expects an array, the failure may appear somewhere else—perhaps as courses.map is not a function.

Check the status before reading successful data

Use response.ok to check whether the response has a status between 200 and 299:

async function loadCourses() {
  const response = await fetch("/api/courses");

  if (!response.ok) {
    throw new Error(
      `Could not load courses: HTTP ${response.status}`
    );
  }

  const courses = await response.json();

  if (!Array.isArray(courses)) {
    throw new Error("Expected the API to return an array");
  }

  return courses;
}
Enter fullscreen mode Exit fullscreen mode

This example assumes /api/courses is an endpoint in your application that returns a JSON array. Replace the URL and validation to match your backend.

The array check only verifies the outer structure. If each course needs an id and a title, validate those fields too before relying on them.

The distinction between HTTP errors and rejected requests is documented in MDN’s Fetch guide.

Make the JSON request reusable

If several screens need the same HTTP-status check, extract a small helper:

async function fetchJSON(url, { signal } = {}) {
  const response = await fetch(url, {
    signal,
    headers: {
      Accept: "application/json",
    },
  });

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

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Then keep course-specific validation in the calling function:

async function loadCourses({ signal } = {}) {
  const courses = await fetchJSON("/api/courses", { signal });

  if (!Array.isArray(courses)) {
    throw new Error("Expected a course array");
  }

  return courses;
}
Enter fullscreen mode Exit fullscreen mode

This helper is deliberately for responses with a JSON body. A successful 204 No Content response needs different handling because there is no JSON to parse.

Sending Accept: application/json expresses a preference; it does not guarantee that the server returns valid JSON.

Treat cancellation separately from failure

A user might leave the page before the request finishes. You can cancel a fetch using an AbortController:

const controller = new AbortController();

loadCourses({ signal: controller.signal })
  .then((courses) => {
    console.log("Courses loaded:", courses);
  })
  .catch((error) => {
    if (error.name === "AbortError") {
      return;
    }

    console.error("Course loading failed:", error);
  });

// Call this when the request is no longer needed:
// controller.abort();
Enter fullscreen mode Exit fullscreen mode

Wire the cancellation into your page or component’s cleanup mechanism. Each new request should get a fresh controller. Aborting a fetch does not guarantee that the server stops processing a request it has already received.

Cancellation is an expected outcome when the user changes direction. It usually shouldn’t trigger the same message as a broken connection. See MDN’s AbortController reference.

Test more than the successful response

Use a local endpoint or mocked responses to check these cases:

Response or action Expected behaviour
200 with a valid course array Return the courses
200 with [] Show an empty state
200 with an unexpected object Report a data-shape error
404 with valid JSON Throw an HTTP error
500 with an HTML error page Throw before attempting JSON parsing
200 with malformed JSON Report a parsing failure
Cancel an unfinished request Ignore the expected cancellation

An empty list, an HTTP error, and an interrupted request deserve different handling. Keeping those cases distinct makes the interface easier to debug—and easier for people to use.

The next time an API request behaves strangely, check three things: the HTTP status, the response body, and the shape your UI expects.


Author affiliation: Coder Roots.

Disclosure: This article was drafted with AI assistance.

Top comments (0)