Node.js · Recorded experiment

Fetch Returns 200 but response.json() Fails

Reproduce SyntaxError and AbortError after fetch returns a response. A local Node.js server separates HTTP status checks, JSON parsing and response-body cancellation.

Recorded: (UTC) · Node v22.22.3 · darwin 27.0.1 / arm64

Read this article in: 简体中文

A 200 status does not guarantee that response.json() will succeed. Fetch can return a Response while its body still needs to be read. Reading can be aborted, and a complete body can still contain invalid JSON.

This local server exposes three endpoints: a 404 with valid JSON, a 200 with incomplete JSON that ends, and a 200 that leaves a partial body open. The script also tries a wrapper that explicitly checks response.ok. Two rounds separate the HTTP status, parsing and body-read outcomes.

Two recorded runs

The same script in two independent runs
ScenarioRun 1Run 2
404 with valid JSONfetch resolved; status: 404; ok: falsefetch resolved; status: 404; ok: false
200 with invalid JSONStatus: 200; json() rejected: SyntaxErrorStatus: 200; json() rejected: SyntaxError
Wrapper with an explicit status checkExplicit error: HTTP 404; invalid JSON: SyntaxErrorExplicit error: HTTP 404; invalid JSON: SyntaxError
Abort body reading after receiving 200Received 200; json() rejected after abort: AbortErrorReceived 200; json() rejected after abort: AbortError

Check HTTP errors explicitly

The first fetch call returns a Response with status 404 and ok set to false. Its body parses successfully into an object with an error field. Wrapping fetch in try/catch alone does not turn that HTTP response into a rejected promise.

The test wrapper checks response.ok before parsing. If it is false, it cancels the remaining body and throws an error containing the status. Requesting the same missing endpoint through that wrapper produces HTTP 404 as an explicit application error.

A JSON content type does not fix a broken body

The second endpoint returns 200 and Content-Type: application/json, but ends after an incomplete object prefix. Fetch returns the response. Parsing its completed body then fails with SyntaxError.

The script checks the error name rather than the exact wording of a parser message. When debugging your own endpoint, inspect the status, content type and a bounded body sample. Avoid logging sensitive payloads wholesale: an error response may still contain credentials or user data.

response.writeHead(200, { "Content-Type": "application/json" });
response.end('{"incomplete":');
// The observed response is 200; json() rejects with SyntaxError.

Abort can still interrupt response-body reading

The third endpoint flushes its headers, writes part of a JSON object and keeps the connection open. After fetch resolves with status 200, the client starts response.json() and calls controller.abort(). Body reading rejects with AbortError in this run.

This gives three separate questions to ask when a request fails: did you receive a response, did reading finish, and did parsing succeed? Treating all three as the same network-error message loses information that the reproduction makes visible.

const response = await fetch(url, { signal: controller.signal });
const pendingBody = response.json();
controller.abort();
await pendingBody; // Observed rejection: AbortError

Keep the timeout active through body consumption

The getJson wrapper passes a 5000 ms timeout signal into fetch and returns the body-reading promise from the same async operation. It checks the status first. In both rounds, the missing resource produces the explicit HTTP 404 error and invalid JSON retains its SyntaxError.

The partial-body test uses a deliberate abort to control ordering; it does not wait five seconds or simulate a real connection outage. The observation is that body consumption can still fail after a successful status has arrived. Stopping the client from reading also does not establish that a remote business operation was rolled back.

async function getJson(url) {
  const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
  if (!response.ok) {
    await response.body?.cancel();
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}

Run the experiment on your machine

Use Node.js 22 or later. No dependencies need to be installed. The script uses Node built-in modules, runs two rounds and checks assertions. Choose a new record path so you do not overwrite an earlier result.

node --version
node run.mjs
node run.mjs --record my-results.json

All requests go to a local server on a random loopback port. This does not test browser CORS, proxies, TLS, actual network loss or cancellation of server-side business work. HTTP success, a parsed body and application success remain separate checks.

Both rounds were run by the same operator on one machine. No independent reader rerun has been recorded. Save your own output when trying another environment; a failed assertion stops the script.

Check the script SHA-256

45ba61e8fdad5dec4e37ce3ecabb7690b8ed7515f663396c28fb878b176ffba1

This digest identifies the downloadable script. Full observations are in verification.json.