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?
answer
- dropping a reference stops nothing
- backpressure pauses, it does not close
- abort the request, cancel the body
- a locked stream cannot be cancelled directly
- abort rejects a read, cancel ends it
basics
~20 sDropping 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.
solid answer
~50 sNothing cleans itself up on your behalf. `fetch()` resolves at the headers, so when it settles the download is still in progress and `response.body` is an un-consumed `ReadableStream`. Backpressure means the browser stops pulling once the stream's internal queue fills, but the request stays open and its resources are released only when the stream is cancelled — or, unpredictably, when the `Response` is garbage collected, which you should never design around. Say what you mean instead: if you no longer want the request at all, `controller.abort()`; if you already have what you need from the status and headers and only want to throw the body away, `response.body.cancel()`, which discards it cleanly. If a reader currently holds the lock, cancel through the reader, because calling `cancel()` on a locked stream throws. And a read loop should sit in a `try`/`catch`: an abort mid-stream rejects the pending `read()` rather than ending it with `done`.
code
javascript · 7 lines// Read only what you need, then release the rest explicitly.
async function probe(url) {
const res = await fetch(url);
const type = res.headers.get('content-type');
await res.body?.cancel(); // discard the payload; do not just drop the Response
return { status: res.status, type };
}go deeper
Know that a Response whose body you never read is not automatically cleaned up, and that abort() is how you stop a request you have decided you no longer want.
Explain what backpressure does for an unread body and the difference between three actions: abandoning a Response, cancelling its body, and aborting the whole request.
Show the operational angle — abandoned bodies present as requests that never finish and transfers that accumulate — and structure a read loop with try/catch and releaseLock so a mid-stream abort is handled rather than escaping.
Own the contract for a streaming client: who is allowed to cancel, how cancellation propagates from the caller to the consumer of the stream, and the rule you hold every code path to — a Response is read, cancelled, or aborted, never dropped.
## What fetch() has actually done by the time it resolves A `fetch()` promise settles as soon as the response head — status, headers — is available. At that instant the body may be one byte in or nearly complete. `response.body` is a `ReadableStream` attached to that live transfer. Reading it drains the transfer; ignoring it does not end it. This surprises people because JavaScript's usual habit is that unreachable objects simply vanish. A stream connected to a network transfer is not that: it is a handle on external state. ## Backpressure is not cleanup A fetch body stream is backpressured. If nobody reads, the stream's internal queue fills and the browser stops pulling bytes from the network. That bounds memory growth — which is why an abandoned response does not immediately blow up the tab — but it is not termination. The request is still open, the connection is still associated with it, and the server is still holding whatever it holds for that request. In a streaming-heavy client the symptom is requests that sit in the network panel forever and a slow accumulation of transfers that never resolve. The transfer is torn down when the stream is cancelled, or eventually when the `Response` object is collected. Garbage-collection timing is not observable, not deterministic and not part of any contract you can build on, so treating it as your cleanup path is a defect. ## The three verbs **`controller.abort()`** — you no longer want the request. Everything stops; if the `fetch()` promise has not settled it rejects, and if it has, the body stream is errored so any pending read rejects too. **`response.body.cancel()`** — you want the response, but not its payload. You have read the status and headers, or you already decided from `response.ok` that the body is useless. `cancel()` discards the remaining data and closes the stream cleanly, letting the browser release the transfer. ```js const res = await fetch(url); const type = res.headers.get('content-type'); await res.body?.cancel(); // explicit: throw the payload away ``` **`reader.cancel()`** — the same, when a reader already holds the lock. `getReader()` locks the stream, and calling `cancel()` on a stream that is locked throws a `TypeError`; you must go through the reader, or `releaseLock()` first and then cancel the stream. `reader.cancel()` also resolves any pending `read()` with `done: true`, which is the clean, non-error way out of a loop. ## Reading loops must expect rejection The distinction between cancel and abort shows up exactly here. A `cancel()` ends the stream: pending reads resolve with `done: true`. An `abort()` **errors** the stream: pending reads reject. So a loop written as `while (true) { const { value, done } = await reader.read(); ... }` with no `try` around it will throw out of the loop when the request is aborted — which is correct behaviour, but only if someone is catching it and treating it as cancellation rather than data corruption. ```js try { for (;;) { const { value, done } = await reader.read(); if (done) break; consume(value); } } catch (err) { if (err.name !== 'AbortError') throw err; } finally { reader.releaseLock(); } ``` ## Why this matters more than it looks Three situations make it bite. Long-lived streaming responses, where an abandoned body may never end on its own. Clients that issue many requests and use only some — a prefetcher, a probe that only wants the status, a race where one winner is kept. And any code path that returns early after checking `response.ok`, which is the most common place a body is silently left hanging. The habit worth forming is that every `Response` leaves a function in one of exactly three states: its body has been fully read, its body has been cancelled, or the whole request has been aborted. "Dropped on the floor" is not one of them.
- What happens to a pending read() when you call reader.cancel() rather than aborting?`cancel()` closes the stream, so a pending `read()` resolves with `{ value: undefined, done: true }` — the loop exits by its normal path. An abort errors the stream instead, so the same pending read rejects. That difference is exactly why a read loop needs a `try`/`catch`: one of the two ways out arrives as an exception.
- Why does calling response.body.cancel() throw in some code paths?Because `getReader()` locks the stream, and cancelling a locked stream throws a `TypeError`. Once you have taken a reader, that reader owns the stream: cancel through `reader.cancel()`, or call `reader.releaseLock()` first and then cancel the stream. The same rule catches `pipeThrough`, which locks the stream it consumes.
- If the response was not ok and you are going to throw, does the body still need handling?Yes — an error response has a body like any other, and returning early leaves it un-consumed. Either read it, because error bodies usually carry the message you want to report, or cancel it explicitly before throwing. This early-return path is the most common place a transfer is quietly left hanging.
saying these in an interview costs you the question
- Assumes dropping the Response ends the download
- Thinks an unread body is discarded automatically
- Calls cancel() on a stream a reader still locks
- Expects a mid-stream abort to end with done: true
- Relies on garbage collection to close transfers