
Keep fetch timeouts active through body reads
In short: Keep the abort signal attached while reading a Fetch response body; receiving HTTP headers does not mean the JSON payload has arrived.
javascript ▸ export async function readJSON(url, signal = AbortSignal.timeout(5000)) {
const response = await fetch(url, { signal });
if (!response.ok) {
await response.body?.cancel();
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
}The HTTP status was 200. The JSON body never finished. In a local Node.js 22.15.0 test on 11 October 2026, fetch() returned a response while the server still held the body open. Aborting afterwards rejected the body read.
Keep fetch timeouts active through body reads, not just until headers arrive. For a tool that promises a JSON result, my completion boundary is the usable result—not the first response object. Otherwise, a reassuring request timeout can leave the part your application actually needs waiting indefinitely.
Why fetch resolves before the body finishes
The WHATWG Fetch Standard separates the response from its body stream. Receiving headers allows response handling to begin while more body bytes are still arriving. That is useful for streaming, but it makes await fetch(url) a different boundary from await response.json().
Watch for a wrapper that starts an abort timer, awaits fetch, clears the timer and then returns the response. Its timer no longer covers subsequent body consumption. A successful header exchange is not proof of a complete payload, and checking response.ok does not change that.
This is also why timing only the first await gives an incomplete picture of a JSON tool call. Record header arrival separately if it helps diagnosis. Don't label it end-to-end completion.
Keep fetch timeouts attached to the JSON read
Save this as read-json.mjs for a trusted endpoint with small JSON responses. The exact helper was exercised with Node.js 22.15.0 and its bundled Undici 6.21.2; it needs no package installation.
export async function readJSON(url, signal = AbortSignal.timeout(5000)) {
const response = await fetch(url, { signal });
if (!response.ok) {
await response.body?.cancel();
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
}
Node's globals reference documents AbortSignal.timeout(delay). Here, a fresh signal is created for each call unless the caller supplies one. It remains associated with the fetch while the helper consumes the body; there is no timer cleared at header arrival.
Import the function and call await readJSON(yourTrustedUrl). Five seconds is an illustrative policy, not a measured ideal. A supplied signal replaces that default, so a caller passing a non-expiring signal has deliberately removed the helper's default timeout.
The helper rejects non-success HTTP statuses and cancels an unwanted body rather than parsing it as success. Malformed JSON fails separately. It does not collapse every failure into an empty object that might look like a valid tool result.
The local stalled-body check
The synthetic HTTP server bound only to loopback. Its stalled route sent headers and a partial JSON fragment, then never ended the response. The checks produced these outcomes:
| Fixture | Observed outcome |
|---|---|
| Complete JSON response | Expected object returned |
| HTTP 503 | Helper rejected |
| Malformed JSON | SyntaxError |
| Abort after HTTP 200 headers | Unfinished body read rejected |
| Stalled body with a timeout signal | Helper rejected |
Five local behavioural checks; no external service or latency benchmark.
The decisive assertion came after the response existed: start reading its body, abort the controller, and assert that the body promise rejects. A separate check supplied a 100-millisecond timeout signal to the helper. That value was a test input, not a claim about precise scheduling or production performance.
My judgement for a retrieval tool
For a hypothetical agent retrieval tool, I would keep the request, body read and result validation within one documented completion contract. The code above covers the first two stages; your schema and business checks still belong after parsing. No client deployment or real agent integration was tested here.
There are important limits. Undici's Fetch documentation notes that JSON body methods buffer the payload. A timeout is not a byte limit: untrusted or large responses need bounded streaming instead. Nor does an abort signal pre-empt synchronous parsing or establish that a remote server rolled back an action.
What's in it for you
- Add a headers-first, stalled-body fixture to your HTTP tool tests.
- Check body completion before recording a JSON request as successful.
Stop the clock at the result your tool promised, not at the headers that announced it.
Sources
- Node.js — Global objects, Node 22 documentation, accessed 11 October 2026.
- WHATWG — Fetch Standard, accessed 11 October 2026.
- Undici — Fetch API documentation, main branch, accessed 11 October 2026.


