Fetch JSON and separate a failed request from a failed status

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.

Language: JavaScript
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 };
  }
}

Language: JavaScript
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 TypeError that gives you no status, because the status is not exposed cross-origin without Access-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 after accept.

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.ok is a range test on status, so a 204 No Content is ok while its body is empty and json() 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

Examples that treat this as a prerequisite

Derived at build time from the editorial graph; no edge is invented here.

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References