What a fulfilled fetch promise tells you

In a modern browser, fetch() returns a promise for a Response object. That promise usually fulfills when the browser receives an HTTP response, even if the server responds with a status such as 404 or 500. A fulfilled promise therefore means “there is a response to inspect,” not “the request succeeded.” Check response.ok before using the body. This property is true for HTTP status codes in the 200–299 range; response.status gives you the numeric code.

A request can also fail before an HTTP response is available. For example, a connection problem or a browser security restriction may cause fetch() to reject. In that case, there is no Response whose status you can check. Finally, receiving a successful HTTP response does not guarantee that its body contains valid JSON. Calling response.json() is another asynchronous operation, and it can reject if the body cannot be parsed.

Build a small JSON loader

The following function is intended for a modern browser. Pass it an endpoint supplied by your application. To try it against a real service, run the page from a local development server rather than opening it as a file, and use an endpoint that permits the browser request. The optional request argument also lets you exercise the function without a server.

async function loadJson(endpoint, request = fetch) { let response; try { response = await request(endpoint, { headers: { Accept: "application/json" } }); } catch (error) { throw new Error("Request could not be completed", { cause: error }); } if (!response.ok) { throw new Error(`HTTP ${response.status}`); } try { return await response.json(); } catch (error) { throw new Error("Response did not contain valid JSON", { cause: error }); } }

The first try block covers failure to obtain a response. The status check then rejects HTTP errors explicitly. The final try block identifies a different problem: the request returned a successful status, but its body could not be read as JSON. The Accept header tells the server what format the client would prefer; it does not force the server to send JSON. Because the function reads the body only after the status check, it does not attempt to parse an error response. That is a reasonable default when the caller only needs the HTTP status.

Handle the result in an interface

Call the loader from an async function that owns the relevant part of the interface. Set a loading state before the request, render the data after success, and clear the loading state in a finally block. In catch, show a useful message such as “Could not load items. Please try again.” Avoid displaying raw error details to users: an HTTP code or parsing message may help a developer diagnose the issue but may not tell a visitor what to do.

Keep the three failure categories in mind when deciding whether a retry is useful. A temporary connection problem may succeed later. An HTTP error depends on what the server reported; repeating the same request may not help. Invalid JSON suggests that the response format and the client’s expectations disagree. For debugging, inspect the original error and its cause in the browser console, but design the visible message around the user’s next step.

Test each path without relying on a server

The optional request argument makes the loader’s behavior reproducible in a modern browser console. First define loadJson, then run await loadJson("ignored", async () => new Response('{"ready":true}', { status: 200 })). The mock request ignores its endpoint, so no network request is made. The result should be an object with ready set to true.

Change the mock response to new Response("Not found", { status: 404 }) to check the HTTP error path. Use new Response("not JSON", { status: 200 }) to check the parsing path. For a request failure, pass async () => { throw new TypeError("Connection failed"); } instead of a function returning a Response. Run each call inside try and catch if you want to inspect its message without an unhandled rejection. These cases demonstrate why one generic catch around fetch() and response.json() can hide an important distinction: an unsuccessful HTTP response, a failed request, and an unreadable JSON body call for different debugging steps.

Back to category ↗