skip to content

Streaming Bodies and AbortController

You will learn how to consume a response incrementally instead of waiting for the whole payload, and how to cancel a request that is no longer wanted. Interviewers ask this for progress UIs, LLM token streams, and race-condition cleanup in components.

on this pageshow

questions

4

In the browser, fetch() has no timeout option. How do you make a fetch request give up after five seconds?

level: juniorimportance: must knowfreq 62%

answer

  1. there is no timeout init option
  2. the deadline comes from a signal
  3. one static does timer and controller
  4. clock starts when the signal is made
  5. wall-clock for the whole exchange

basics

~10 s

Drive it from an abort signal: fetch(url, { signal: AbortSignal.timeout(5000) }) aborts the request when the timer fires. The manual equivalent is an AbortController plus setTimeout calling controller.abort(), with the timer cleared afterwards.

solid answer

~50 s

`fetch()` takes no timeout, so the deadline comes from an `AbortSignal`. The one-liner is `fetch(url, { signal: AbortSignal.timeout(5000) })` — widely available in browsers since 2022 — and the browser tears the request down when the timer fires. The manual equivalent, still needed for older targets, is to create an `AbortController`, pass `controller.signal` into the init object, arm `setTimeout(() => controller.abort(), 5000)`, and `clearTimeout` in a `finally` so a fast response does not leave a live timer that aborts nothing. Two things worth saying out loud: the deadline is wall-clock for the whole exchange, not an inactivity timeout — it starts when the signal is created and will cut a slow body off mid-stream — and because `fetch()` resolves at the headers, a timeout that lands after that surfaces on the body stream rather than on the `fetch()` promise.

code

javascript · 19 lines
javascript
// Modern form: the signal owns both the timer and the controller.
async function getJson(url, ms = 5000) {
  const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
  if (!res.ok) throw new Error('HTTP ' + res.status);
  return res.json();
}

// Manual equivalent for older targets.
async function getJsonManual(url, ms = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    const res = await fetch(url, { signal: controller.signal });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

go deeper

for a junior

Remember that fetch() has no timeout option and that a deadline comes from a signal. Be ready to write fetch(url, { signal: AbortSignal.timeout(5000) }) on the spot.

for a middle

Explain the manual AbortController plus setTimeout form, including clearing the timer in a finally block and why every request needs its own controller rather than a shared one.

for a senior

Talk about where the deadline actually bites: it is wall-clock over the whole exchange, so a slow body is cut mid-stream, and once the headers have resolved the abort surfaces on the body stream instead of the fetch() promise.

for a principal

Be ready to argue a timeout policy for a whole client: one shared request helper, separate budgets for reaching the headers versus draining the body, and how deadlines compose with retries so a user is not made to wait the budget several times over.

## Why there is no timeout option `fetch()` is specified in terms of a single, general cancellation primitive rather than a bespoke option per concern. Anything that wants to stop a request — a deadline, a user pressing Cancel, a view being torn down, a newer request superseding this one — goes through the same door: the `signal` init option. So the answer to "how do I add a timeout" is really "how do I abort on a timer". ## The short form ```js const res = await fetch('/api/report', { signal: AbortSignal.timeout(5000) }); ``` `AbortSignal.timeout(ms)` is a static that returns a signal which aborts itself after `ms` milliseconds. There is no controller to hold and no timer to clear — the signal owns both. When it fires, the request is torn down and the abort surfaces as a `DOMException` whose `name` is `TimeoutError`, which is how you tell a deadline apart from a cancel you triggered yourself. One subtlety: the clock starts when the **signal is created**, not when `fetch()` is called. Creating signals up front and using them later burns part of the budget before the request begins, so create the signal at the call site. ## The manual form ```js const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); try { const res = await fetch(url, { signal: controller.signal }); return await res.json(); } finally { clearTimeout(timer); } ``` Three details matter. The `clearTimeout` belongs in a `finally` so it runs on both paths — a stray timer that fires later is harmless for the request but keeps a closure alive and, in a reused-controller design, can abort the wrong thing. The `return await` inside the `try` is deliberate: without `await`, the `finally` runs before the JSON body has been read, and the timer is cleared while the transfer is still in flight. And each request needs its **own** controller: a signal that has aborted stays aborted forever, so a reused controller makes every subsequent `fetch()` reject immediately. ## What the deadline actually covers This is where candidates go wrong. It is not a connect timeout, not a read timeout, and not an inactivity timeout. It is a wall-clock deadline over the entire operation. A response that begins streaming at 200 ms and is still streaming at 5 s will be cut off, mid-body, at the deadline. If you want an inactivity timeout instead — "abort only if nothing has arrived for two seconds" — you have to build it: read the body chunk by chunk and re-arm your own timer on each chunk, aborting the controller when it expires. ## Where the failure appears `fetch()` resolves once the headers are in. If the deadline fires before that, the `fetch()` promise itself rejects. If it fires afterwards, the promise has already settled and the abort lands on the **body**: the response's stream is errored, so a pending `reader.read()`, or the `response.json()` you were awaiting, rejects instead. Code that wraps only the `fetch()` call in a `try` and reads the body outside it will let that rejection escape. ## Race is not cancel A very common wrong answer is to race the fetch against a timer promise. That makes your code stop waiting, but the request keeps running: bytes keep arriving, the connection stays busy, and the response handler may still execute. Only an abort signal actually stops the browser's work. Racing is a control-flow trick; aborting is cancellation. ## Practical shape In real applications the timeout usually belongs in one shared request helper rather than at every call site, because a per-call default is where inconsistency creeps in. It also needs to compose with whatever forwards a caller-supplied signal — a helper that ignores the signal it was handed, and only applies its own deadline, silently removes the caller's ability to cancel.

  • What does AbortSignal.timeout(5000) give you that a controller plus setTimeout does not?
    Less to get wrong. There is no timer handle to clear, no controller to keep alive, and no chance of reusing an already-aborted controller. It also aborts with a `TimeoutError` rather than whatever reason you would have passed, so a deadline is distinguishable from a user-initiated cancel. The tradeoff is that you cannot abort it early yourself — for that you still need your own controller, or a combined signal.
  • The deadline fires after the response headers have already arrived. Where does the failure show up?
    Not on the `fetch()` promise — that resolved when the headers landed. The abort errors the response's body stream, so it surfaces on whatever is reading the body: a pending `reader.read()` rejects, and so does an in-progress `response.json()` or `response.text()`. Code that only guards the `fetch()` call itself will miss it entirely.
  • Why is racing fetch against a rejecting timer not a timeout?
    Because it only stops your code from waiting. The request itself is untouched: the browser keeps downloading, the connection stays occupied, and any work chained to the response still runs. It also hides the failure, since nothing tells the transport to stop. Passing a signal into `fetch()` is what actually cancels the request.

saying these in an interview costs you the question

  • Claims fetch() accepts a timeout init option
  • Races fetch against a timer and calls it cancelled
  • Reuses one AbortController across many requests
  • Forgets to clear the timer after a fast response
  • Treats an aborted request as a server-side failure

context

open as a page

Using the browser Fetch API, how do you consume a response incrementally instead of awaiting response.text(), and what problem does piping response.body through a TextDecoderStream solve?

level: middleimportance: should knowfreq 45%

basics

~20 s

response.body is a ReadableStream of byte chunks: call getReader() and loop on read() until done. Piping it through TextDecoderStream turns those bytes into text correctly even when a multi-byte character is split across two chunks.

open as a page

You call fetch() in the browser, look at the response headers, and then decide you do not need the payload — so you never read response.body and just drop the reference. What actually happens to the transfer, and what should you have done?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Dropping the reference does not end the transfer: the body stream sits un-consumed and its resources are held until it is cancelled or the object is collected. Call response.body.cancel() to discard the body, or controller.abort() to kill the whole request.

open as a page

A search box fires a fetch on every keystroke and renders whatever comes back. Users occasionally see results for a query they have already replaced. What is happening, and how do you fix it?

level: seniorimportance: should knowfreq 55%

basics

~20 s

Responses complete out of order, so a slower earlier request can land after a newer one and overwrite it. Cancel the previous request with its own AbortController before starting the next, and additionally drop any response whose query no longer matches the current input.

open as a page