← back to the archiveCover illustration for “Keep fetch timeouts active through body reads”
TILday 133·today·Published ·by Andy Padia

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:

FixtureObserved outcome
Complete JSON responseExpected object returned
HTTP 503Helper rejected
Malformed JSONSyntaxError
Abort after HTTP 200 headersUnfinished body read rejected
Stalled body with a timeout signalHelper 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

#javascript#testing
← older drop
SlopMonster is a smoke test, not an editor

related drops

explore all 385 drops →
← back to the archiveday 133