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);
}
}
Suppose the server returns:
HTTP/1.1 404 Not Found
Content-Type: application/json
With this body:
{
"message": "Route not found"
}
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;
}
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();
}
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;
}
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();
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)