JavaScript · Tech

Keep the deadline alive through the body read

A successful response header can hide a timeout that stopped protecting the JSON read.

  1. Cleanup can happen too earlyReturning a pending JSON promise runs finally while the body is still arriving.
  2. Await keeps the read in scopeUse return await when finally must clear the timer after the JSON read settles.
  3. Test a delayed body separatelyEvery helper passed our slow-headers test. Two failed to enforce the intended body deadline.

A fetch timeout can miss a slow JSON response because its timer has already been cleared. In a helper that calls clearTimeout() in finally, return response.json() starts reading the body and then leaves the try block. Cleanup runs while that promise is still pending. Use return await response.json() when the timer must remain active through the body read, or pass a timeout signal that stays connected to the response.

The distinction is easy to miss because both versions eventually give the caller parsed JSON. On a fast connection, they can look identical. We tested them against a local server that sent headers immediately but deliberately delayed the end of its JSON body. The version without await accepted the late result; the version with await aborted it.

One return changes when cleanup runs

async function getJSON(url, timeoutMs = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch(url, {
      signal: controller.signal
    });
    return response.json(); // The body promise is still pending.
  } finally {
    clearTimeout(timer); // Runs before that promise settles.
  }
}

The helper creates a timer and passes its controller’s signal into fetch(). That part is connected correctly. The problem appears at the last line of the try block: response.json() returns a promise for reading and parsing the body. Returning that promise begins the function’s exit, so finally clears the timer immediately.

The promise returned by the async helper still adopts the eventual JSON result. Its caller can remain waiting long after the helper’s cleanup has run. That is the surprising combination: the caller is still waiting, but the timer is gone.

This follows from ECMAScript’s try/finally evaluation rules and the suspension behavior of await. It is a reason to inspect cleanup scope, not a rule that every async return needs an extra await. Without completion-dependent cleanup or local error handling, returning a promise can be appropriate.

Fast headers expose the missing deadline

The Fetch Standard separates receiving a response from consuming its body. Our loopback server made that separation visible: it flushed a 200 response and the beginning of a JSON document, then waited 650 ms before sending the remaining bytes. Each client used a 180 ms timeout.

We ran four variants in Node.js 24.19.0 and Chrome 153.0.8010.50 on 25 September 2026. The results below show elapsed time from starting each client operation to fulfillment or rejection. These are individual observations on one Windows machine, not latency benchmarks or timing guarantees.

Local experiment · 180 ms timeout

Headers now, final body bytes 650 ms later

Clear at headers

Node 24.19.0 Fulfilled · 674 ms

Chrome 153 Fulfilled · 659 ms

Return JSON promise

Node 24.19.0 Fulfilled · 669 ms

Chrome 153 Fulfilled · 662 ms

Return await JSON

Node 24.19.0 Rejected · 192 ms

Chrome 153 Rejected · 187 ms

Native timeout signal

Node 24.19.0 Rejected · 197 ms

Chrome 153 Rejected · 186 ms

The first variant deliberately clears its timer as soon as fetch() returns a Response; its caller reads the body afterward. It demonstrates a timeout scoped to headers. The second looks more careful because the JSON read appears inside try, yet its pending promise is returned before the cleanup block runs.

In the second Chrome case, headers arrived at 2 ms and cleanup also ran at 2 ms. The JSON result arrived at 662 ms. Adding await changed the order: the abort fired at 187 ms, then cleanup and rejection followed. The relevant evidence is that ordering, not whether a timer fired at exactly 180 ms.

We also delayed the headers themselves by 650 ms. All four variants rejected in both runtimes. A test that only makes the server slow before sending headers would therefore have passed every helper, including the two that failed to protect the body. Across the two delay locations, four variants and two runtimes, all 32 assertions passed.

Three stages show headers arriving, a returned JSON promise triggering early cleanup, and return await preserving the abort timer until settlement
Follow the body read through the cleanup point. This diagram concerns a helper that clears its manual timer in finally; the native timeout signal remains effective after headers. Tap for full resolution.

Keep the body read inside the try block

The manual-timer repair is small. Keep the timer alive until the operation the helper promises to complete has settled. Here the helper promises parsed JSON, so its protected scope includes that read.

async function getJSON(url, timeoutMs = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch(url, {
      signal: controller.signal
    });
    if (!response.ok) {
      await response.body?.cancel();
      throw new Error('HTTP ' + response.status);
    }
    return await response.json(); // Keep the read inside try.
  } finally {
    clearTimeout(timer);
  }
}

The final await suspends execution inside try. If the body arrives in time, the helper obtains the parsed result and then clears the timer. If the signal aborts the read, execution leaves through rejection and the same cleanup runs. Chrome’s abortable-fetch documentation explicitly includes response-body consumption in the work an abort can stop.

This example deliberately returns data rather than an open Response. A wrapper that hands its caller a response stream needs a different ownership contract: the signal must remain effective while that caller consumes the stream, and cleanup cannot assume that receiving headers finished the job.

The status check is separate from the timeout. An HTTP error response still needs handling; cancelling its unused body avoids leaving that read open in this helper. Applications that need an error payload can read a bounded error body under the same active signal instead. The example is intended for a small JSON endpoint; it does not implement a response-size limit or a general streaming client.

A timeout signal still reaches the body

For a supported runtime, the compact alternative avoids manual timer cleanup entirely:

async function getJSON(url, timeoutMs = 5000) {
  const signal = AbortSignal.timeout(timeoutMs);
  const response = await fetch(url, { signal });
  if (!response.ok) {
    await response.body?.cancel();
    throw new Error('HTTP ' + response.status);
  }
  return response.json(); // No manual finally clears this signal.
}

AbortSignal.timeout does not lose its effect merely because fetch has resolved. Our native-signal variant aborted the delayed body in both runtimes. This is a useful distinction when diagnosing advice that treats every timeout as if it ended at the headers: the early cutoff came from our manual cleanup.

There was a second, smaller difference worth recording. With the delayed body, Node reported TimeoutError, while Chrome reported AbortError. With delayed headers, both reported TimeoutError. A production error handler should retain the signal and inspect its state and reason alongside the caught exception; the error name alone did not identify the timeout consistently across our two tested body paths. This kind of phase-dependent exception behavior also appears in the Undici issue record, although that historical issue is not evidence for every current version.

For an API diagnostic, record the stage reached, whether the signal aborted, its reason, and the caught error. The same discipline helps when Chrome reports an ambiguous API availability status: one output can be too coarse to identify the failed condition.

Reproduce the failure with a deliberately unfinished body

Run this tiny Node server in a terminal, then call each helper against http://127.0.0.1:8787/ with a timeout of 180 ms. Stop the server with Ctrl+C when finished. A Node client avoids browser cross-origin setup; our browser measurement used a same-origin page on the test server.

// Save as slow-body.cjs and run: node slow-body.cjs
const http = require('node:http');
http.createServer((request, response) => {
  response.writeHead(200, {
    'Content-Type': 'application/json',
    'Cache-Control': 'no-store'
  });
  response.flushHeaders();
  response.write('{"ok":');
  const timer = setTimeout(() => response.end('true}'), 650);
  response.on('close', () => clearTimeout(timer));
}).listen(8787, '127.0.0.1');

The flushed headers and first fragment are essential. If the entire response is delayed together, you are testing the easier header case. Check both variants explicitly: the broken helper should fulfill after the delayed bytes arrive, while the repaired helper should reject before that completed body becomes available.

This fixture is intentionally small and local. For a regression test, log headers received, abort fired, timer cleared and result settled; assert their order and the outcome instead of requiring an exact millisecond. Our full comparison added a slow-headers route and ran the same client variants in both runtimes.

A working timeout still has limits

The browser timeout signal uses active time, which can pause while a document is in the back-forward cache or a worker is suspended. Timers also do not interrupt arbitrary synchronous JavaScript. Do not read a configured duration as a guarantee that every step of parsing and application work will stop at that exact wall-clock instant.

A client abort also does not establish whether a server-side mutation already happened. If a save times out, retrying can require checking server state and preserving edit preconditions; our autosave conflict analysis covers that separate write problem. Likewise, successfully receiving JSON says nothing about how fresh its content is; stale-while-revalidate has its own freshness rules.

For the cleanup bug, the decisive test remains modest: send headers promptly, keep the body pending, and verify that the deadline still has a live connection to the read. A helper can pass the first half of that test while failing the operation its caller actually needs.

Questions about JSON timeouts

Does native AbortSignal.timeout stop at the headers?

No. In our test its signal aborted the delayed response body in both runtimes. The premature cutoff came from clearing a manual timer.

Is return await always required in an async function?

No. It matters here because finally must wait for the body operation before clearing the timer. Returning a promise can be appropriate when there is no completion-dependent cleanup.

Why did my timeout test pass with the broken helper?

If the server delayed the headers, fetch was still pending when the timer fired. Send headers promptly and delay the remaining body to test the separate read phase.

Can a rejected body read still be a timeout?

Yes. Chrome reported AbortError for our body-phase native timeout, while Node reported TimeoutError. Retain the signal and inspect its state and reason alongside the exception.

Specifications, documentation and test provenance

Documentation checked 25 September 2026. Original test: Node.js 24.19.0 and Chrome 153.0.8010.50; isolated loopback server, 180 ms timeout and 650 ms delay; two delay locations, four variants, 16 cases and 32 passing assertions. Observed times are single-run results, not benchmarks. Runner and event logs are retained with the editorial sources.