~/blog

Reading a fetch Response twice throws 'Body is unusable'

published

#javascript#node#fetch#http

TL;DR

A Response body is a stream, and every body method (text(), json(), arrayBuffer(), blob(), formData()) consumes it. Call a second one and Node rejects with TypeError: Body is unusable: Body has already been read. The common trigger is logging an error body with await res.text() and then falling through to res.json(). Read the body once into a variable and parse that, or call res.clone() before the first read. The same rule applies to a Request with a body, which breaks retry wrappers that pass the same Request object to fetch twice.

The problem

This looks like careful error handling: when the upstream returns an error, log what it said, then carry on.

import http from 'node:http'

const server = http.createServer((req, res) => {
  res.writeHead(422, { 'content-type': 'application/json' })
  res.end(JSON.stringify({ error: 'email is required' }))
})
await new Promise((resolve) => server.listen(0, resolve))
server.unref()
const url = `http://localhost:${server.address().port}/users`

async function createUser() {
  const res = await fetch(url, { method: 'POST' })
  if (!res.ok) {
    console.error('upstream said:', await res.text())
  }
  return res.json()
}

try {
  await createUser()
} catch (err) {
  console.log(`${err.name}: ${err.message}`)
}
upstream said: {"error":"email is required"}
TypeError: Body is unusable: Body has already been read

The log line is fine. The exception is the problem: the caller gets a TypeError about stream state instead of anything that mentions HTTP 422, so whatever handles the error upstream sees a programming bug, not a validation failure. The happy path never runs the text() call, so tests that only cover successful responses pass. (This is also the branch you need in the first place, because fetch does not reject on HTTP errors.)

The examples below reuse url and the server from this block.

Why it happens

The Fetch Standard defines it directly. A Request or Response is “unusable if its body is non-null and its body’s stream is disturbed or locked”, and the shared algorithm behind text(), json() and the other body-reading methods starts with: “If object is unusable, then return a promise rejected with a TypeError.”

Reading a stream disturbs it, and a disturbed stream cannot be rewound: the bytes were delivered once and are not kept. bodyUsed reports it:

const res = await fetch(url, { method: 'POST' })
console.log(res.bodyUsed)
await res.text()
console.log(res.bodyUsed)
false
true

Nothing caches the first read for you. json() is not a view over text(); both are independent consumers of the same one-shot stream.

What to do

Read once, then parse the string yourself. This is the fix for the logging case, and it keeps the error message useful:

async function createUser() {
  const res = await fetch(url, { method: 'POST' })
  const raw = await res.text()
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${raw}`)
  }
  return JSON.parse(raw)
}

try {
  await createUser()
} catch (err) {
  console.log(`${err.name}: ${err.message}`)
}
Error: HTTP 422: {"error":"email is required"}

It also means a non-JSON error page (an HTML 502 from a proxy, say) ends up in the error message instead of failing inside res.json() with a SyntaxError.

Clone before the first read when two consumers really need the body, such as a logging middleware and the code that parses it:

const res = await fetch(url, { method: 'POST' })
const copy = res.clone()
console.log('logged:', await copy.text())
console.log('parsed:', await res.json())
logged: {"error":"email is required"}
parsed: { error: 'email is required' }

The clone has to happen before anything reads. Afterwards it is too late:

const res = await fetch(url, { method: 'POST' })
await res.text()
try {
  res.clone()
} catch (err) {
  console.log(`${err.name}: ${err.message}`)
}
TypeError: Response.clone: Body has already been consumed.

Cloning has a cost on large bodies. MDN puts it plainly: “If only one cloned branch is consumed, then the entire body will be buffered in memory.” For a JSON payload that is irrelevant; for a multi-gigabyte download, read once instead.

Clone the Request, not just the Response, in retry wrappers. A request body is a stream too. This wrapper retries a 503 by handing the same Request to fetch again:

async function fetchWithRetry(request, tries = 2) {
  for (let i = 1; i <= tries; i++) {
    const res = await fetch(request)
    if (res.status !== 503) return res
    await res.body?.cancel()
  }
}

const req = new Request(url, { method: 'POST', body: '{"n":1}' })
try {
  await fetchWithRetry(req)
} catch (err) {
  console.log(`${err.name}: ${err.message}`)
}

Run against a server that answers 503 to the first attempt (not the 422 server above), the second attempt throws before anything is sent:

TypeError: Cannot construct a Request with a Request object that has already been used.

The first fetch sent the body, so the Request is now unusable, and the spec’s Request constructor (which fetch runs on its input) throws when “inputBody is unusable”. Passing request.clone() to each fetch call fixes it: with that one change, the second attempt reaches the server with the body intact and fetchWithRetry resolves with the 200.

Caveats

References