Fetch JSON and separate a failed request from a failed status
Canonical URL: https://devexamples.com/javascript/fetch-json-and-handle-http-errors/
The problem
fetch resolves for a 404 or a 500, so a try/catch around await fetch() reports success for an error page and the real failure surfaces later as a confusing JSON parse error.
Short answer
Check response.ok after the await, catch only transport failures, and parse the body in its own step so each failure keeps its own meaning.
Three different failures hide behind one catch block. Naming them separately is what makes
the error message useful.
export async function loadJson(url, { signal, headers } = {}) {
let response;
try {
// Rejects only on transport problems: DNS, connection refused, CORS, abort.
response = await fetch(url, { signal, headers: { accept: 'application/json', ...headers } });
} catch (error) {
if (error.name === 'AbortError') return { ok: false, reason: 'aborted' };
return { ok: false, reason: 'network', message: error.message };
}
if (!response.ok) {
// 401, 404, 500: the server answered, and the status is the information.
return {
ok: false,
reason: 'status',
status: response.status,
statusText: response.statusText,
};
}
try {
return { ok: true, data: await response.json(), etag: response.headers.get('etag') };
} catch {
// A 200 whose body is not JSON is its own failure, often a captive portal.
return { ok: false, reason: 'body', status: response.status };
}
}const result = await loadJson('/api/orders', { signal: controller.signal });
if (result.ok) {
render(result.data);
} else if (result.reason === 'status' && result.status === 401) {
showSignIn();
} else if (result.reason === 'status' && result.status === 404) {
showEmptyState();
} else if (result.reason === 'aborted') {
// Expected when the user navigates away; not an error worth reporting.
} else {
showRetry(result.reason);
}Explanation
fetch rejects when the request could not be completed at all. It resolves with a Response
for every HTTP status, including 500, because from the transport’s point of view the exchange
finished. Splitting the two cases into separate blocks is therefore not defensive style, it is
the shape of the API. Once the branches are separate, each one can carry a different user
action: a 401 asks for sign-in, a 404 asks for a different address, a network failure asks for
a retry, and an abort asks for nothing at all.
The third branch is the one most implementations skip. Reading response.json() on a body that
is not JSON throws, and if the parse sits inside the same try as the request, a malformed body
is reported as if the network had failed. Keeping the parse separate also lets you report the
status together with the body problem, which is how you recognise an HTML login page served at
status 200 by a proxy.
Returning a plain result object instead of throwing keeps the caller’s branches explicit and
avoids a caller forgetting a catch entirely. If you prefer exceptions, throw a typed error that
carries status, because the caller still needs the same three distinctions.
Assumptions
- The endpoint returns
application/json; for text or binary, replace the parse step and keep the branch structure. - Relative URLs are same-origin. A cross-origin call needs the server’s CORS response to allow it, and a blocked call rejects with a generic
TypeErrorthat gives you no status, because the status is not exposed cross-origin withoutAccess-Control-Allow-Origin.
Parameters
url(string, required): the request address.signal(AbortSignal, optional): cancellation handle; see the AbortController example.headers(object, optional): extra request headers, merged afteraccept.
Expected output
An object of either { ok: true, data, etag } or { ok: false, reason } where reason is
network, status, body, or aborted, with status included for the latter two.
Caveats
response.okis a range test onstatus, so a 204 No Content isokwhile its body is empty andjson()will throw; treat 204 as a success with no data.- Reading the body consumes it, so a second
response.json()on the same response throws.
Follow-up
Follow-up
Group array items by a computed keyA parsed payload usually needs reshaping before the view can use it.
Examples that treat this as a prerequisite
Derived at build time from the editorial graph; no edge is invented here.
- Cancel an in-flight fetch with AbortController
Read a response and its failure kinds before deciding to abort one.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Cancel an in-flight fetch with AbortControllerCancelling a request is the other half of controlling its lifecycle.
Alternative approach
Type a generic fetch hook that returns a known payloadThe same request task wrapped as a typed reusable hook.
Same task or topic
Narrow an unknown API response with type guardsShares the Fetch and HTTP topic
References
- Using Fetch(opens in a new tab) — MDN. Explains that fetch rejects only on network error, not on HTTP status.
- Response.ok(opens in a new tab) — MDN. Documents the status-range test that separates 2xx from 4xx and 5xx.
Source page: https://devexamples.com/javascript/fetch-json-and-handle-http-errors/