Web Platform · Progressive Web
Request Cloning and Body Streams in Fetch Handlers
Why a fetch handler cannot both return a response and store it without cloning, how tee behaves under backpressure, and which bodies the Cache API refuses to store.
Inside a fetch handler, the same Response object is often wanted twice: once as the answer to the page and once as a cache entry. Bodies are streams, and a stream can be read once. The API surfaces this as a TypeError with a message about an already used body, thrown at the moment the second consumer tries to read. Understanding why requires looking at what clone() actually does to the stream underneath.
Bodies are streams with a single reader
A Request or Response produced by fetch() can carry a body as a ReadableStream. Reading it through text(), json(), or arrayBuffer() disturbs it: bodyUsed becomes true and a second read rejects. Taking a reader with body.getReader() adds a lock rather than necessarily disturbing the body, and clone() refuses a locked body as well as a disturbed one.
const response = await fetch('/data.json');
const a = response.clone(); // Fine: body neither disturbed nor locked.
const parsed = await response.json();
// const b = response.clone(); // TypeError: body already used
const text = await a.text(); // The clone has its own reader.
clone() is specified as a structural copy of the headers, status, URL list, and redirect state, plus a tee of the body stream. A tee creates two branches that receive the same chunks in order. This is the reason cloning does not duplicate memory in the naive sense: chunks are shared between branches, but only while both branches demand them at a comparable rate.
Backpressure and the tee trap
A tee buffers for the slower branch. If one branch is consumed immediately and the other is left unread, every chunk that the fast branch pulls is retained for the slow branch. The retained bytes are the entire body minus what the slow branch has consumed, and nothing is released until the slow branch is read or cancelled. Cloning a response in order to return one copy immediately while a background task stores the other is safe only if the background task starts promptly; a cache.put() initiated inside waitUntil does, but a clone stashed in a variable for later may not.
The failure mode is subtle: a 2 MB JSON response cloned and partially consumed retains the unread portion in memory until the slow branch is read or the worker is terminated, which is costly when the worker is also handling concurrent requests. Streaming a large media file through clone() is worse, because the branch feeding the cache can fall arbitrarily far behind: every chunk the page consumes is also queued for the slower branch, so the worker accumulates the download in memory rather than passing it through.
For large bodies, the ordering should be inverted. Pass the network response straight through to the page and store nothing; or store first from a clone and only then respond, accepting the delay; or, for media, avoid the Cache API entirely and let the HTTP cache handle range requests, which it does natively and the Cache API does not.
The clone-then-store pattern
The standard shape of a cache-on-fetch handler puts the clone before any consumption and keeps the write on the event’s lifetime:
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
const network = fetch(event.request);
let store = Promise.resolve();
const response = network.then((res) => {
if (res.ok && res.type === 'basic') {
const copy = res.clone(); // Body untouched: no consumer has read it.
store = caches.open('runtime-v1').then((cache) => cache.put(event.request, copy));
}
return res;
});
// Called before the handler returns; waits for the write that response.then starts.
event.waitUntil(response.then(() => store).catch(() => {}));
event.respondWith(response);
});
Three details in that snippet are load-bearing. The clone happens in the same callback that hands the response back, before any consumer reads the body. event.waitUntil() is called synchronously, while the event is still being dispatched; the promise it receives waits for the store chain that the callback assigns, so the worker stays alive until the write finishes. And the catch is deliberate: a storage failure must not turn a successful network response into a rejected respondWith.
cache.put() receives the request that was actually made, so the key includes the query string and matches later lookups. Storing under a normalized URL — stripping tracking parameters, for instance — requires constructing a separate Request for the key, because the cache uses the Request object’s URL, not a string passed alongside.
cache.put() reads the response body to completion before the entry becomes visible. This is why the call belongs in waitUntil: the promise must be kept alive past the response, or the worker may be terminated mid-write and the entry left absent. It is also why put() cannot store a live stream indefinitely — an endless stream never completes the write, and the promise never settles.
The Cache API rejects some responses outright. A 206 Partial Content response throws TypeError, because the cache stores complete representations and a byte range is not one. A response whose body is already disturbed throws for the same reason as clone(). A response with Vary: * is storable but never matches a later request. And an opaque response is storable through put() even though its body cannot be read by script, at the storage cost described in Cache Storage Keys, Vary, and Cache Versioning.
Reading a body while forwarding it
Inspecting a response — counting bytes, rewriting a header, injecting markup — without buffering the whole body requires a TransformStream inserted into the stream chain. The stream’s transform callback sees each chunk as it passes and can enqueue a modified or unmodified copy.
self.addEventListener('fetch', (event) => {
event.respondWith((async () => {
const upstream = await fetch(event.request);
if (!upstream.body) return upstream; // Opaque and 204 responses have no readable body.
let total = 0;
const counter = new TransformStream({
transform(chunk, controller) {
total += chunk.byteLength; // Chunks are Uint8Array for byte streams.
controller.enqueue(chunk);
}
});
const headers = new Headers(upstream.headers);
headers.delete('Content-Length'); // Unknown until the stream ends.
return new Response(upstream.body.pipeThrough(counter), {
status: upstream.status,
headers
});
})());
});
The counter’s total is complete only after the page has read the body. Three constraints apply to that construction. The Response constructor does not carry over the original URL, so response.url differs from the network response; anything that needs the URL must read it from upstream before wrapping. A network response’s header list is immutable, so Content-Length must be removed from a copied Headers object rather than from upstream.headers. And a wrapped stream has no known length: the browser frames a constructed response by the stream’s end, so a stale Content-Length truncates or errors the transfer; omitting the header is always correct for a streamed body.
What buffering costs
arrayBuffer(), blob(), text(), and json() each read the stream to its end before settling. A 4 MB compressed JSON payload that expands to 12 MB occupies 12 MB as an ArrayBuffer, and text() converts it to a JavaScript string, which for a mostly-ASCII document costs roughly one to two bytes per character depending on the engine’s representation, before json() allocates the object graph on top. Inside a worker that is simultaneously handling other requests, that peak is the worker’s memory, and the browser may terminate a worker that grows too large.
Streaming avoids the peak at the price of complexity: a transform can inspect chunks incrementally, but it cannot inspect a parsed structure without assembling it. Where the goal is only to cache a response, no buffering by script is needed at all — cache.put() consumes the body itself and writes it out. Where the goal is to rewrite a header, the body need not be touched: construct a new Response with upstream.body as the body and the modified headers, and the stream passes through with no intermediate copy.
Reading a body twice is never possible, but it is often attempted in a try/catch that treats the resulting TypeError as a network failure, producing a confusing error path. Checking response.bodyUsed before deciding which consumer gets the body makes the intent explicit and avoids the catch block entirely.
Requests with bodies
Non-GET requests reach the handler like any other, and event.request.body is the upload stream. Cloning such a request works under the same rules, but a handler that re-fetches it must not consume the body first. fetch(event.request) after reading event.request.text() rejects with a TypeError, because the request’s body has already been consumed and a used request cannot be fetched again.
The request body also affects what a handler can honestly claim. A handler that answers a POST from cache without reading the request body never observes the upload, and the browser has no way to know whether the user’s data reached the origin. A synthesized success page in that situation reports an outcome that did not happen, and there is no request-side signal to correct it later. For form submissions, the practical approach is to let the request reach the network and handle failure at the application layer rather than to synthesize a response in the worker. The background retry facilities intended for exactly that case are covered in Background Sync and Deferred Work.
References
- Fetch Standard — Bodies — WHATWG
- Streams Standard — WHATWG
- Cache.put() — MDN Web Docs