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
| Scenario | Run 1 | Run 2 |
|---|---|---|
| 404 with valid JSON | fetch resolved; status: 404; ok: false | fetch resolved; status: 404; ok: false |
| 200 with invalid JSON | Status: 200; json() rejected: SyntaxError | Status: 200; json() rejected: SyntaxError |
| Wrapper with an explicit status check | Explicit error: HTTP 404; invalid JSON: SyntaxError | Explicit error: HTTP 404; invalid JSON: SyntaxError |
| Abort body reading after receiving 200 | Received 200; json() rejected after abort: AbortError | Received 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: AbortErrorKeep 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.jsonAll 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.