Quick answer: Test who reads a Fetch response, when a reader locks it, when cloning happens, what remains after a parse failure, and how no-content responses bypass parsing. A response body is a stream; storing the Response object does not store a reusable copy of its JSON.
Adding an error logger can break an otherwise working client if the logger consumes the body first. These five tests make that ownership visible. For request setup, status checks, and retries, start with the broader Fetch JSON in JavaScript guide.
The JavaScript blocks below run together, in order, as one .mjs file. They were executed with Node.js v24.22.1 using local Response fixtures. They exercise body semantics without an HTTP server; they do not establish browser, proxy, or network interoperability. Wikipedia's stream computing overview provides background on sequential data access; the linked platform documentation defines the behavior tested here.
1. Does your client consume each JSON response body only once?
A Fetch response with a non-null body supports one consumption of that stream, not one read per method. Test that the first JSON read produces the expected value and a second read rejects. Give one component ownership of the body, then pass the parsed value to other application components.
Begin with a small response whose expected value is obvious. This separates stream handling from complicated payload validation. The assertion on the result matters: a test that only checks whether parsing completes can miss an unexpected array, scalar, or error object. MDN documents response.json() as reading the stream to completion and returning the parsed JavaScript value.
import assert from "node:assert/strict";
const once = new Response('{"orderId":"A-1042","paid":true}');
assert.equal(once.bodyUsed, false);
const order = await once.json();
assert.deepEqual(order, { orderId: "A-1042", paid: true });
assert.equal(once.bodyUsed, true);
await assert.rejects(() => once.json(), TypeError);
await assert.rejects(() => once.text(), TypeError);
Both later operations must reject. Changing the second method from JSON to text does not create a new source of bytes. Error messages differ by implementation, so asserting the error class avoids tying the test to a particular sentence from one engine.
In application tests, install the real logging or tracing middleware around this fixture. Let the normal request path process it, then check that the caller still receives the order. If the test starts failing after instrumentation is added, inspect every body-reading operation in that path. A status logger that only reads headers has different effects from one that awaits the full text.
Once parsing succeeds, use the JSON Viewer to inspect a sanitized payload, expand nested objects, and copy paths to suspicious values. The viewer helps examine the document; it cannot establish which middleware consumed the original response stream. Share parsed data with explicit ownership rules as well: passing one mutable object to several components can introduce ordinary JavaScript mutation bugs even after the stream problem is solved.
2. Can an unread stream lock block your JSON reader?
An unread response body can be locked even when bodyUsed is false. Test that attaching a reader blocks JSON consumption, then release the lock without reading and confirm parsing succeeds. Treat bodyUsed as a record of disturbance, not a complete permission check for every possible body operation.
This is an easy boundary to miss when adding download progress, custom decoders, or stream adapters. ReadableStream.getReader() attaches a reader and locks the stream. The bodyUsed documentation describes whether the body has been used; it does not promise that no reader currently owns it.
const locked = new Response('{"ready":true}');
const reader = locked.body.getReader();
assert.equal(locked.bodyUsed, false);
assert.equal(locked.body.locked, true);
await assert.rejects(() => locked.json(), TypeError);
assert.throws(() => locked.clone(), TypeError);
reader.releaseLock();
assert.equal(locked.body.locked, false);
assert.deepEqual(await locked.json(), { ready: true });
The fixture deliberately does not call the reader's read method. That detail makes the successful recovery meaningful: the lock was released before anything consumed the bytes. MDN's releaseLock reference explains the reader lifecycle and the effect on pending reads. Releasing a lock is not a rewind operation.
Add this scenario wherever your code hands a Response to an extension point. A plugin that takes a reader and forgets to release it can break later parsing without changing the visible body-used flag. Do not paper over the problem by checking that flag and assuming the next operation must work. Handle the operation's actual rejection and make ownership part of the plugin contract.
For a regression report, record the runtime version, whether the body is null, the body-used value, and the lock state before consumption. Include the smallest fixture that reproduces the behavior. Those observations are more actionable than a screenshot showing only a TypeError. Avoid attaching real customer response bodies when reporting a stream-state problem; synthetic bytes usually suffice to demonstrate ownership.
3. Is a necessary response clone created before consumption?
Create a response clone before either consumer reads the original body, then test both branches with a small fixture. A clone made after consumption must fail for a non-null body. Use cloning only for a deliberate second consumer, because differently paced branches can accumulate buffered data and increase memory use.
Sometimes separate consumers are justified, such as an integration layer that must deliver a Response while also inspecting a small controlled fixture. The timing belongs in a test rather than in a code review assumption. MDN's clone reference describes the method and warns about buffering when stream branches are consumed at different rates.
const original = new Response('{"items":["pen","paper"]}');
const copy = original.clone();
const [parsed, raw] = await Promise.all([
original.json(),
copy.text(),
]);
assert.deepEqual(parsed, { items: ["pen", "paper"] });
assert.equal(raw, '{"items":["pen","paper"]}');
assert.equal(original.bodyUsed, true);
assert.equal(copy.bodyUsed, true);
assert.throws(() => original.clone(), TypeError);
The two branches are consumed concurrently here. That is appropriate for this tiny fixture, but it does not prove bounded memory use with a large download or a stalled consumer. Keep the distinction explicit in your test names and release notes. A successful functional assertion is not a load test.
Prefer passing the parsed result to downstream code when both consumers need the same data. If one consumer needs a diagnostic string, decide whether retaining a redacted summary is enough. Cloning every response simply to print it can turn a small observability change into substantial additional buffering. Payloads can also contain credentials, payment details, or personal information that never belonged in logs.
Before enabling a clone in production, exercise the real adapter with representative payload sizes and one intentionally slow consumer. Observe memory and cancellation behavior in that environment; these local assertions do not supply those measurements. For genuinely incremental data, the streaming JSON API guide covers framing and chunk handling. Reading an entire response as JSON remains a different operation from processing independently framed records as they arrive.
4. Does a failed JSON parse preserve the original failure?
A syntax error from response.json() does not restore an already consumed non-null stream. Test that malformed JSON rejects with SyntaxError and a later text read rejects with TypeError. If diagnostics require the original text, read it once before parsing, then preserve the parse error without attempting another body read.
This test protects the catch path. A client can correctly detect malformed JSON and then replace that useful failure with a body-state error while trying to log the response. The caller loses the original explanation, making a server payload problem look like a second unrelated client bug.
const malformed = new Response('{"orderId":');
await assert.rejects(() => malformed.json(), SyntaxError);
assert.equal(malformed.bodyUsed, true);
await assert.rejects(() => malformed.text(), TypeError);
const diagnostic = new Response('{"orderId":');
const rawDiagnostic = await diagnostic.text();
assert.throws(() => JSON.parse(rawDiagnostic), SyntaxError);
assert.equal(rawDiagnostic, '{"orderId":');
The second fixture demonstrates an alternative ownership decision, not a universal logging recommendation. Reading the entire body as text still buffers the payload, and retaining it increases the lifetime of potentially sensitive information. Apply the application's response-size limits and redaction policy before adopting that approach. Keeping a short preview after a full text read does not limit the memory needed for that read.
The Fetch Standard's body algorithm distinguishes an unusable stream from conversion failure. That distinction informs the assertions: a parser rejection and a rejection caused by stream ownership are different outcomes. Also avoid assuming every SyntaxError means the same malformed character. An empty document or a truncated value can fail parsing too.
When triaging an actual incident, capture approved metadata first: status, content type, request identifier, and the failing stage. Consult the JSON parse error guide for syntax diagnosis after obtaining a safe copy of the body. A syntax check on that copy can establish that its text is invalid; it cannot prove whether the network truncated the original response or some intermediary generated it.
5. Does your JSON reader distinguish null bodies from empty streams?
A null response body and an empty non-null stream both fail JSON parsing, but their stream state differs. Test both fixtures explicitly and handle documented no-content statuses before parsing. Do not replace every empty response with a default object, because an empty success response can violate the API's data contract.
The constructor makes the difference visible: omitting the body creates a null body; passing an empty string creates a stream with no content. For no-content responses, the Fetch Standard's Response rules also restrict construction with a body. The following fixture uses a null body with status 204.
const noContent = new Response(null, { status: 204 });
assert.equal(noContent.body, null);
await assert.rejects(() => noContent.json(), SyntaxError);
assert.equal(noContent.bodyUsed, false);
assert.equal(await noContent.text(), "");
const emptyStream = new Response("");
assert.notEqual(emptyStream.body, null);
await assert.rejects(() => emptyStream.json(), SyntaxError);
assert.equal(emptyStream.bodyUsed, true);
await assert.rejects(() => emptyStream.text(), TypeError);
async function readDocumentedJson(response) {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (response.status === 204) return undefined;
return response.json();
}
assert.equal(await readDocumentedJson(
new Response(null, { status: 204 })
), undefined);
await assert.rejects(() => readDocumentedJson(new Response("")), SyntaxError);
console.log("All five response-body test groups passed");
The helper is intentionally narrow: this endpoint contract allows 204 or a successful JSON document. Adapt that contract explicitly if your API uses HEAD, conditional requests, or other no-content behavior. It does not validate content type, application fields, or error payloads. The JSON content-type guide covers the header contract separately.
Avoid a catch block that returns an empty object for every parsing error. It can hide a broken deployment while allowing later application code to proceed with missing fields. Choose a distinct representation for documented absence, and test callers against it. A list endpoint returning an empty array is also different from an endpoint returning no bytes at all.
Keep these five groups beside your client wrapper tests, then add integration coverage for the actual server and supported runtimes. Record which failure stage each assertion protects: ownership, locking, clone timing, parsing, or absence. That separation makes a future failure easier to diagnose without treating every response problem as invalid JSON.
When adapting the fixtures, replace the direct parsing call with the smallest public entry point that owns your response. Keep the assertions on returned data and failure classes. Run the same fixtures with instrumentation enabled and disabled so a logger cannot quietly change the client contract. Create a fresh Response for each independent test: reusing one fixture across tests introduces order dependence and can make a correct client look broken. Finally, keep the no-content case separate from malformed content in your error reporting. They require different fixes, and combining them under a generic empty-result fallback removes evidence your support team needs.