Reading a fetch Response twice throws 'Body is unusable'
published
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
-
Empty responses hide the bug. A response with a null body, such as a 204, is never unusable, so reading it twice works. A 200 with an empty body is not null and still fails:
/204 body is null: true /204 second read: ok /200 body is null: false /200 second read -> TypeError: Body is unusable: Body has already been readA test suite that mocks error responses with a 204 will not catch the double read.
-
bodyUsedcan sayfalseand the read still fails. Taking a reader withres.body.getReader()locks the stream without disturbing it.bodyUsedstaysfalse,text()rejects anyway, and Node’s message still says “Body has already been read”. A helper that wants to know whether the body can still be read should checkres.body?.lockedas well asbodyUsed. -
The opposite mistake is not reading at all. Undici, the client behind Node’s
fetch, warns that relying on garbage collection to release unread bodies can exhaust connections, and says “it is important to always either consume or cancel the response body anyway”. That is why the retry loop above callsres.body?.cancel()on the response it throws away. -
The wording differs by runtime. The messages quoted here are Node’s. Browsers reject with a
TypeErrortoo, with their own text, so match on the type, not the message. -
Everything above was run on Node v26.2.0.