964 words
5 minutes

Fetch catch Doesn't Run on 404: Check Status Before JSON

2026-10-05
Frontend
JavaScript
/
HTTP
/
Frontend
/
Troubleshooting

If fetch() receives HTTP 404, its promise can still resolve. A catch handler does not automatically run because the server returned an error status. Check response.ok before parsing the body, and throw an error when your application treats that status as failure.

There is a second trap: response.json() can reject because an error page contains HTML. That rejection describes a parsing failure and can hide the more useful HTTP status. A successful 204 response also has no JSON body to parse.

Put the HTTP check before the JSON parser#

MDN’s fetch reference distinguishes request failures from HTTP error responses. Receiving a 404 response is enough to fulfill the fetch promise; your code decides whether to reject it. The Response.ok property is true for status codes 200 through 299.

For a GET endpoint that promises JSON on success and allows 204 as an empty result, use this helper:

async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.name = 'HttpError';
error.status = response.status;
throw error;
}
// This endpoint contract treats 204 as a successful empty result.
if (response.status === 204) return null;
return response.json();
}

Await getJson(url) inside your try block, or attach .catch() to its returned promise. The helper throws before attempting to interpret an error body. It does not retain that body; if the API exposes structured error details, add a separate error-body policy after preserving the status.

Returning null for 204 is an application choice. The Fetch Standard defines 204 as a status without a body, but does not prescribe a JavaScript return value for your helper. If your endpoint requires a JSON object on every successful request, treat 204 as a contract violation instead. This example is for GET requests, not a general wrapper for HEAD or every possible bodyless status.

Reproduce the three different error paths#

Save the following as fetch-status-demo.mjs and run node fetch-status-demo.mjs. It uses Node.js 24 and its built-in fetch, starts a loopback HTTP server on an available port, and needs no package or external API. The helper is repeated so the file runs independently.

import { createServer } from 'node:http';
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.name = 'HttpError';
error.status = response.status;
throw error;
}
// This endpoint contract treats 204 as a successful empty result.
if (response.status === 204) return null;
return response.json();
}
const routes = {
'/ok-json': [200, 'application/json', '{"value":1}'],
'/missing-json': [404, 'application/json', '{"error":"missing"}'],
'/missing-html': [404, 'text/html', '<!doctype html><h1>Missing</h1>'],
'/ok-html': [200, 'text/html', '<!doctype html><h1>App shell</h1>'],
'/empty': [204, null, ''],
};
const server = createServer((request, response) => {
const [status, type, body] = routes[request.url] ?? routes['/missing-html'];
response.writeHead(status, type ? { 'Content-Type': type } : {});
response.end(body);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
async function outcome(operation) {
try {
return `resolved ${JSON.stringify(await operation())}`;
} catch (error) {
return `rejected ${error.name}${error.status ? ` ${error.status}` : ''}`;
}
}
try {
for (const path of Object.keys(routes)) {
const url = base + path;
const response = await fetch(url);
console.log(`${path}: fetch resolved status=${response.status} ok=${response.ok}`);
await response.body?.cancel();
console.log(` unchecked: ${await outcome(async () => (await fetch(url)).json())}`);
console.log(` checked: ${await outcome(() => getJson(url))}`);
}
} finally {
await new Promise(resolve => server.close(resolve));
}

The exact fixture was executed during preparation on macOS with Node.js 24.13.0 on October 5, 2026. It produced:

/ok-json: fetch resolved status=200 ok=true
unchecked: resolved {"value":1}
checked: resolved {"value":1}
/missing-json: fetch resolved status=404 ok=false
unchecked: resolved {"error":"missing"}
checked: rejected HttpError 404
/missing-html: fetch resolved status=404 ok=false
unchecked: rejected SyntaxError
checked: rejected HttpError 404
/ok-html: fetch resolved status=200 ok=true
unchecked: rejected SyntaxError
checked: rejected SyntaxError
/empty: fetch resolved status=204 ok=true
unchecked: rejected SyntaxError
checked: resolved null

Each operation makes a fresh request. The first response’s body is canceled after reading its metadata, so it is not reused for either parser check. The output prints error classes rather than engine-specific parsing messages.

The important difference is /missing-json: JSON parsing succeeds even though the HTTP status is 404. Without the status check, both the request and parser resolve. With /missing-html, the unchecked parser rejects, but the HTTP failure is disguised as SyntaxError.

Diagnose the layer that failed#

The Response.json() reference documents a SyntaxError when the body cannot be parsed as JSON. Parsing success does not establish HTTP success, and response.ok does not establish a valid JSON payload.

Observed resultWhat the fixture establishesNext check
404 with valid JSON resolves in unchecked codeThe error body is valid JSON; no HTTP check was appliedCheck status before parsing
404 HTML produces SyntaxErrorThe parser obscured the HTTP failurePreserve the 404, then inspect the response body
200 HTML still produces SyntaxError with the helperThe status passes, but the payload violates the JSON expectationInspect the requested URL, final response URL, content type, and body
204 produces SyntaxError in unchecked codeAn empty successful response is not JSONDefine the endpoint’s no-content return policy

For a real endpoint, inspect those response details in the browser’s Network panel before changing the parser. A 200 HTML body tells you what arrived; it does not by itself prove whether a login redirect, wrong route, or fallback page caused it. The fixture deliberately returns an app shell to demonstrate that boundary.

A rejection from await fetch(url) before a response exists belongs to another branch of the diagnosis. MDN lists network errors, invalid requests, and aborts among fetch failures. The loopback example does not test those failures, browser CORS enforcement, redirects, or service workers; browser behavior should be checked in the affected environment.

For a shell request failing to stop a CI job, use the separate curl HTTP error and pipeline checks. JavaScript promises and shell exit statuses require different fixes.

Keep the order explicit: receive the response, decide whether its status is acceptable, handle an allowed empty result, then parse the promised payload. A catch becomes useful when the code preserves which of those operations failed.

References:

Fetch catch Doesn't Run on 404: Check Status Before JSON
https://laplusda.com/en/posts/fetch-catch-http-404-json-errors/
Author
Zero
Published at
2026-10-05
License
CC BY-NC-SA 4.0