skip to content

A retry wrapper builds one Request object for a POST and passes that same object to fetch() on every attempt. The first attempt fails and the retry throws a TypeError before any network activity happens. What is wrong, and how do you write the wrapper correctly?

level: seniorimportance: should knowfreq 30%

answer

  1. a Request owns its body
  2. sending drains it
  3. bodyUsed is sticky
  4. clone while it is still pristine
  5. GET has nothing to consume

basics

~20 s

A Request carries a one-shot body, and sending it consumes that body. The second fetch on the same Request finds bodyUsed already true and throws a TypeError. Clone the Request before each attempt, or rebuild it from the URL and init.

solid answer

~50 s

`Request` is not an inert description of a call — it owns the body, and the body is a single-pass stream exactly as it is on `Response`. The first `fetch(request)` drains it and sets `request.bodyUsed` to `true`, so the second call fails immediately with a `TypeError` before touching the network. Two fixes work. Take `request.clone()` for each attempt and keep the original pristine — remembering that `clone()` itself throws once the body is disturbed, so the clone must be made *before* the first send. Or, better for a retry wrapper, keep the URL and the init object and construct a fresh `Request` per attempt, which sidesteps the whole disturbance question and lets each attempt carry its own `AbortSignal` too. A bodyless GET is unaffected: with no body there is nothing to consume, so reusing the object is harmless.

code

javascript · 19 lines
javascript
// Broken: the same Request is consumed on attempt 1.
async function badRetry(request, attempts) {
  for (let i = 0; i < attempts; i++) {
    try { return await fetch(request); } catch { /* attempt 2 throws TypeError */ }
  }
}

// Fixed: send clones and keep the original pristine.
async function retry(request, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fetch(request.clone());
    } catch (error) {
      lastError = error;
    }
  }
  throw lastError;
}

go deeper

for a junior

Know that a Request with a body can only be sent once, and that a retry needs a newly built request rather than the same object handed to fetch() again.

for a middle

Explain the mechanism: Request carries the same one-shot body stream as Response, fetch() disturbs it, bodyUsed becomes true, and clone() only works on an undisturbed body — so the clone must precede the first send.

for a senior

Show the wrapper you would actually ship: a factory that produces a fresh Request per attempt, a fresh signal per attempt, and a clear rule about which requests are eligible to be repeated at all. Recognise the TypeError as local, not a network failure.

for a principal

Set the retry contract for the whole codebase — one client that owns request construction so no caller can reuse a consumed object, an explicit policy on which operations may be repeated, and telemetry that separates client-side construction faults from genuine transport failures.

## A Request is a value that owns a body It is natural to read `new Request(url, init)` as a description of a call — a bundle of URL, method, headers and payload that you could hand to `fetch()` as often as you like. That reading is wrong in exactly one respect, and it is the respect that breaks retries. `Request` includes the same body machinery as `Response`. It has `bodyUsed`, it has `body` as a `ReadableStream`, and it has the reading methods `text()`, `json()`, `arrayBuffer()`, `blob()` and `formData()`. The body is a single-pass source. Anything that consumes it — including handing the request to `fetch()` — disturbs it permanently. ```js const request = new Request('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sku: 'A-19' }) }); await fetch(request); // body drained console.log(request.bodyUsed); // true await fetch(request); // TypeError, thrown synchronously-ish, no network ``` The second failure is not a network error and never reaches the server. That is the tell: a retry that fails instantly, with no entry in the network panel, is a client-side body-disturbance bug, not an outage. ## Why the platform does it this way A body can be a `ReadableStream` — an upload being produced as it goes, or piped from another source. There is no general way to rewind such a thing, so the Fetch Standard does not pretend some bodies are replayable and others are not. It gives every body the same one-shot rule, and provides `clone()` as the explicit opt-in for the cases where teeing is possible and wanted. ## Fix one: clone before the first send ```js async function retry(request, attempts) { let lastError; for (let i = 0; i < attempts; i++) { try { return await fetch(request.clone()); // original stays pristine } catch (error) { lastError = error; } } throw lastError; } ``` The crucial detail is that the *original* is never handed to `fetch()` — only clones are. If you send the original first and then try to clone for attempt two, `clone()` throws, because it also demands an undisturbed body. Cloning is not a repair for a spent request; it is a precaution taken while the request is still intact. ## Fix two: build a fresh Request per attempt For a retry wrapper this is the better shape. Keep the ingredients, not the object: ```js async function retry(url, init, attempts) { let lastError; for (let i = 0; i < attempts; i++) { try { return await fetch(url, { ...init, signal: AbortSignal.timeout(5000) }); } catch (error) { lastError = error; } } throw lastError; } ``` Each attempt gets its own body, built fresh from the same immutable string or `Blob`, so disturbance never enters the picture. It also gives each attempt its own signal, which matters because a signal baked into a `Request` is shared by every send derived from it — an abort or timeout that fired on attempt one would still be in the aborted state for attempt two. Constructing a `Request` is cheap and performs no network work, so there is nothing to optimise by reusing one. ## Bodies that cannot be replayed at all If the body is a `ReadableStream`, no strategy recovers it: the bytes were produced once and are gone. A wrapper that must retry such an upload has to be given a factory that can produce a *new* stream per attempt. Streaming request bodies additionally require the `duplex: 'half'` init option, and availability across browsers remains limited and Chromium-led, so a retry design that depends on them is fragile by construction. ## What is safe to hand around Bodyless requests are unaffected. GET and HEAD cannot carry a body, `bodyUsed` never becomes `true`, and the same object can be sent repeatedly — which is why the bug only ever shows up on the mutating paths. Whether those mutating requests *should* be repeated automatically is a separate question about the operation's semantics, and worth settling before the wrapper is written at all.

  • Why does calling request.clone() inside the retry loop, after the first attempt, not fix the bug?
    Because `clone()` requires an undisturbed body. The first `fetch(request)` already consumed it and set `bodyUsed` to `true`, so the clone call throws a `TypeError` of its own — you have just moved the failure one line. The clone must be taken while the `Request` is still unsent, which in practice means before the first attempt.
  • Why is rebuilding a Request from the URL and init on each attempt better than cloning?
    Each attempt gets an independently constructed body, so there is no shared stream to disturb and no ordering rule to remember. It also lets each attempt carry its own fresh signal and its own headers, which a clone would inherit from the original. The cost is trivial: constructing a `Request` performs no network work.
  • Does this affect a GET request built as a Request object?
    No. GET and HEAD cannot carry a body, so there is nothing to consume and `bodyUsed` stays `false`. The same object can be handed to `fetch()` repeatedly. The hazard is specific to bodied methods — POST, PUT, PATCH — which is also where retries need the most care for other reasons.
  • What happens if the request body is a ReadableStream rather than a string or Blob?
    It cannot be replayed at all. A stream body has no retained source to re-read, so neither cloning nor re-sending recovers it; the wrapper must be able to produce a fresh stream per attempt. Streaming request bodies also require the `duplex: 'half'` init option and their availability remains limited and Chromium-led.

saying these in an interview costs you the question

  • Thinks a Request object is inert and infinitely reusable
  • Calls clone() after the first send has already failed
  • Assumes the TypeError came from the network
  • Retries a stream-bodied request expecting the bytes to replay
  • Reuses one Request across attempts to save allocation

context