Cloudflare Workers: "This ReadableStream is disturbed" Fix
TypeError: This ReadableStream is disturbed (has already been read from), and cannot be used as a body. means one specific thing: your Worker already consumed the request body, and a Fetch body is a single-use stream. Nothing is broken in the runtime, the binding or the deploy — a line above the one that threw read the bytes, and every later read is refused by the spec.
The reason this error wastes afternoons is that the same mistake prints a different message depending on which call touches the used body. Reading it twice gives you Body has already been used; cloning too late gives you currently locked to a reader; rebuilding or forwarding the request gives you the disturbed string above; and on Node instead of workerd you get a fifth wording entirely. I reproduced every one of those triggers against workerd 2026-09-11, Node 26.5.1 and Hono 4.13.7 while writing this, so the map below is measured, not paraphrased. After that comes the handler I now ship for signed webhooks: one read, from the stream, into a buffer the rest of the request lifecycle uses.
TL;DR
- A Fetch body is a one-shot stream.
request.text(),.json(),.formData(),.arrayBuffer()and a rawrequest.body.getReader()all consume it. The second consumer throws. - workerd words the double-read case plainly:
TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.This is the message you get from two accessors on one request. - The
disturbedstring in this article's title is the rebuild/forward path:new Request(url, alreadyReadRequest). That is the retry-with-failover and proxy pattern, and it fails at construction time. - Forwarding a used request to a service binding throws a third message:
TypeError: Cannot reconstruct a Request with a used body. clone()is only valid before the first read. Afterwards it throwsTypeError: This ReadableStream is currently locked to a reader,. Clone first, then read.- Node and undici say
TypeError: Body is unusable: Body has already been readfor the same mistakes — worth knowing when the same handler logic runs in a Worker and in a Next.js route. - Hono caches parsed bodies, so
c.req.json()twice is safe — butc.req.rawbypasses that cache, and mixing the two reproduces the error exactly. Put Hono's ownbodyLimitmiddleware in front and read through a cached accessor, and neither the error nor an unbounded buffer is reachable. - The fix is architectural, not defensive: read the body once into bytes with a hard size cap, verify the signature over those bytes, then parse from the buffer. Full Worker and test harness below, executed on workerd and on Node.
Why the body can only be read once
A Request body in the Fetch standard is a ReadableStream, and a stream has a cursor, not a value. The cursor moves forward as you read; there is no rewind(). The spec exposes that state as bodyUsed, and once it flips to true the body is finished for every API that would read it.
Cloudflare's own engineers spell out the full list of ways a body gets consumed, and two of them are not obvious. A body is disturbed when you read it, when you cancel it — and also when you hand it to something else that reads it, which includes constructing new Request(oldRequest) without replacing the body, passing it to fetch(), sending it to the eyeball with event.respondWith(), or putting it in the cache with cache.put(). That last group is why the bug shows up in code that only ever reads headers: the read happened inside the runtime, on the line you handed the object away.
Two practical consequences follow. First, request.bodyUsed is a reliable diagnostic, but only for accessor reads: I measured it staying false after a raw getReader() consumed the entire body, and staying false even after releaseLock(). Second, an uncaught TypeError in a Worker surfaces as an Error 1101 to every caller, which is how a body bug turns into a customer-visible outage instead of a 400.
Reproduce every variant locally with workerd
You do not need a Cloudflare account, a deploy or an API token to see these strings. The workerd binary is published on npm, and a ten-line Cap'n Proto config wires a socket to your module. That is what I used; every output quoted in this article comes from it.
mkdir -p /tmp/wd-lab && cd /tmp/wd-lab
npm install workerd
cat > origin.js <<'EOF'
export default {
async fetch(request) {
const text = await request.text();
return new Response('origin received ' + text.length + ' bytes');
},
};
EOF
cat > config.capnp <<'EOF'
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "main",
worker = (
modules = [ (name = "worker.js", esModule = embed "worker.js") ],
compatibilityDate = "2026-09-01",
bindings = [ (name = "ORIGIN", service = (name = "origin")) ],
)),
(name = "origin",
worker = (
modules = [ (name = "origin.js", esModule = embed "origin.js") ],
compatibilityDate = "2026-09-01",
)),
],
sockets = [ (name = "http", address = "127.0.0.1:8799", http = (), service = "main") ],
);
EOF
./node_modules/.bin/workerd serve config.capnp
The second service is the stand-in for whatever you forward to — a queue consumer, an origin API, another Worker. It exists because two of the six triggers only appear when a request crosses that boundary, and a probe without it answers Cannot read properties of undefined (reading 'fetch') instead of the error you are chasing.
The probe module below puts each trigger behind its own path, so one curl per case tells you exactly which line throws which message. It catches every error and reports the constructor name plus the message, because a bare try/catch that swallows the text is how people lose an hour.
// worker.js — one trigger per path, each on a fresh incoming request
const describe = (err) => err.constructor.name + ': ' + err.message;
export default {
async fetch(request, env) {
const path = new URL(request.url).pathname;
const out = { trigger: path };
try {
switch (path) {
case '/a': // two accessors on one body
out.first = await request.text();
out.second = await request.text();
break;
case '/b': // clone BEFORE the first read
{
const copy = request.clone();
out.original = await request.text();
out.clone = await copy.text();
}
break;
case '/c': // clone AFTER the first read
{
await request.text();
const late = request.clone();
out.lateClone = await late.text();
}
break;
case '/d': // rebuild a Request from a consumed one
{
await request.text();
const rebuilt = new Request('https://origin.example.com/forward', request);
out.rebuilt = await rebuilt.text();
}
break;
case '/e': // forward a consumed request to a service binding
{
await request.text();
const upstream = await env.ORIGIN.fetch(request);
out.upstream = await upstream.text();
}
break;
case '/f': // a raw stream reader, then an accessor
{
const reader = request.body.getReader();
out.chunk = new TextDecoder().decode((await reader.read()).value);
reader.releaseLock();
out.afterReader = await request.text();
}
break;
default:
out.hint = 'use /a /b /c /d /e /f';
}
} catch (err) {
out.error = describe(err);
}
out.bodyUsed = request.bodyUsed;
return new Response(JSON.stringify(out, null, 2), {
headers: { 'content-type': 'application/json; charset=utf-8' },
});
},
};
Send the same 33-byte JSON payload to each path. On workerd 2026-09-11 with compatibility date 2026-09-01, these are the responses, trimmed to the fields that matter:
{
"/a": { "first": "{\"type\":\"order.created\",\"id\":42}", "error": "TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.", "bodyUsed": true },
"/b": { "original": "{\"type\":\"order.created\",\"id\":42}", "clone": "{\"type\":\"order.created\",\"id\":42}", "bodyUsed": true },
"/c": { "error": "TypeError: This ReadableStream is currently locked to a reader,", "bodyUsed": true },
"/d": { "error": "TypeError: This ReadableStream is disturbed (has already been read from), and cannot be used as a body.", "bodyUsed": true },
"/e": { "error": "TypeError: Cannot reconstruct a Request with a used body.", "bodyUsed": true },
"/f": { "chunk": "{\"type\":\"order.created\",\"id\":42}", "error": "TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.", "bodyUsed": true }
}
Path /b is the control: clone() before any read gives two independently readable bodies, and both halves decode. Paths /a and /f show that a raw reader and an accessor are the same consumer — the stream does not care which API drained it. Path /d is the one that produces the error in this article's title, and it throws from the Request constructor, not from the later .text(): the failure happens before a single byte of the new request exists.
The measured trigger-to-message map
The table is the whole point of running the lab. Fill in your own compatibility date if you pin one, but these are the strings as of the September 2026 runtime.
| What your code does | workerd 2026-09-11 | Node 26.5.1 (undici) |
|---|---|---|
Two accessors on one body (text() then text(), text() then json(), formData() after text()) | TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice. | TypeError: Body is unusable: Body has already been read |
request.clone() after the body was read | TypeError: This ReadableStream is currently locked to a reader, | TypeError: unusable |
new Request(url, consumedRequest) | TypeError: This ReadableStream is disturbed (has already been read from), and cannot be used as a body. | TypeError: Response body object should not be disturbed or locked |
env.ORIGIN.fetch(consumedRequest) (service binding) | TypeError: Cannot reconstruct a Request with a used body. | no equivalent — Node has no service bindings |
A raw body.getReader() held open, then request.text() | TypeError: This ReadableStream is currently locked to a reader. | TypeError: Body is unusable: Body has already been read |
body.tee() before any read, one read per branch | both branches decode | both branches decode |
Two details are worth keeping because they look like typos if you meet them in a log. The clone-after-read message ends in a comma while the locked-reader message ends in a period — same runtime, two different code paths, both quoted byte-for-byte above. And a locked reader does not set bodyUsed: in the /f case the flag only turns true after the accessor attempts its read and fails, which is why bodyUsed === false is not proof that a body is still available to you.
If you are chasing this through logs rather than a reproduction, one more number helps: a body drained through a raw reader in a service binding produces the Cannot reconstruct a Request with a used body variant rather than the disturbed one. Both mean the same thing at the source — the request you are handing over has already been read — and both are fixed the same way.
The fix: read the body once, from the stream
Every variant above collapses into one rule: the request body gets exactly one consumer, and that consumer is responsible for producing everything the handler needs. For a signed webhook that means reading the bytes once, verifying the HMAC over those exact bytes, and parsing JSON from the same buffer. No second read, no clone taken while hoping the timing works out.
The reader below adds the part most samples skip: a hard byte cap enforced while streaming, not from a header. Content-Length is attacker-controlled and absent on chunked uploads, so it is a fast reject, never the limit itself.
const MAX_BODY_BYTES = 64 * 1024;
class BodyTooLarge extends Error {}
async function readBoundedBytes(request, limit) {
const declared = Number(request.headers.get('content-length') ?? '0');
if (Number.isFinite(declared) && declared > limit) throw new BodyTooLarge('content-length');
if (!request.body) return new Uint8Array(0);
const reader = request.body.getReader();
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > limit) {
await reader.cancel('body exceeds limit');
throw new BodyTooLarge('stream');
}
chunks.push(value);
}
} finally {
if (reader.releaseLock) reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
Verification then works on the buffer, never on a re-serialised object. The signature is compared with crypto.subtle.verify(), which is the constant-time path — comparing two hex digests with === is a timing oracle, and it also silently fails the moment a JSON round-trip changes byte order.
const REPLAY_WINDOW_SECONDS = 300;
const encoder = new TextEncoder();
function hexToBytes(hex) {
if (hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null;
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
return out;
}
async function verifySignature(secret, rawBytes, header) {
const fields = {};
for (const part of header.split(',')) {
const [k, v] = part.split('=');
if (k && v) fields[k.trim()] = v.trim();
}
const timestamp = Number(fields.t);
const signature = hexToBytes(fields.v1 ?? '');
if (!Number.isFinite(timestamp) || !signature) return { ok: false, reason: 'malformed_header' };
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > REPLAY_WINDOW_SECONDS) return { ok: false, reason: 'stale_timestamp' };
const key = await crypto.subtle.importKey(
'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
// Signed payload is "<timestamp>.<raw body bytes>" — never a re-serialised object.
const prefix = encoder.encode(`${fields.t}.`);
const signed = new Uint8Array(prefix.length + rawBytes.byteLength);
signed.set(prefix, 0);
signed.set(rawBytes, prefix.length);
const ok = await crypto.subtle.verify('HMAC', key, signature, signed);
return { ok, reason: ok ? 'valid' : 'signature_mismatch' };
}
The handler ties them together. Note the ordering: size cap, then signature, then JSON parse. Rejecting on size first costs nothing and keeps an unauthenticated request from allocating 64 KiB of buffer, and a malformed-body 400 is only returned to a caller who already proved possession of the signing secret.
function json(payload, status, extraHeaders = {}) {
return new Response(JSON.stringify(payload), {
status,
headers: {
'content-type': 'application/json; charset=utf-8',
'x-content-type-options': 'nosniff',
'referrer-policy': 'no-referrer',
...extraHeaders,
},
});
}
export default {
async fetch(request, env) {
if (request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'POST' });
const secret = env?.WEBHOOK_SECRET;
if (!secret) return json({ error: 'not_configured' }, 500);
let raw;
try {
raw = await readBoundedBytes(request, MAX_BODY_BYTES);
} catch (err) {
if (err instanceof BodyTooLarge) return json({ error: 'payload_too_large', limit: MAX_BODY_BYTES }, 413);
throw err;
}
const verdict = await verifySignature(secret, raw, request.headers.get('x-signature') ?? '');
if (!verdict.ok) return json({ error: 'invalid_signature', reason: verdict.reason }, 401);
let event;
try {
event = JSON.parse(new TextDecoder().decode(raw));
} catch {
return json({ error: 'invalid_json' }, 400);
}
// From here on, `event` and `raw` are both in hand. Nothing in this Worker
// touches request.body again — which is what makes the whole error family impossible.
return json({ received: true, type: event.type ?? null, id: event.id ?? null, bytes: raw.byteLength }, 200);
},
};
This is not a defensive patch around the error, it is the removal of the condition that produces it: a single read means there is no second read to fail. If your Worker also proxies the request onward — the pattern behind same-zone fetch failures — build the outbound Request from raw instead of from the incoming request, and the disturbed and Cannot reconstruct variants disappear with it.
Testing it on workerd, end to end
The signer below is what the sender would run; it prints the header value, so the test is a copy-paste rather than a hand-computed digest. In the lab the secret reaches the Worker as a text binding — bindings = [ (name = "WEBHOOK_SECRET", text = "whsec_local_lab_only") ] in its config.capnp — and the signer reads the same value from the environment. Neither side stores it in code, which is the habit to keep even when the value is a throwaway.
# sign.mjs prints: t=<unix>,v1=<hex hmac of "<t>.<raw body>">
SIG=$(node sign.mjs '{"type":"order.created","id":42}')
curl -s -o /dev/null -w 'valid signature -> %{http_code}\n' \
-X POST -H "content-type: application/json" -H "x-signature: $SIG" \
-d '{"type":"order.created","id":42}' http://127.0.0.1:8800/webhook
curl -s -o /dev/null -w 'body changed -> %{http_code}\n' \
-X POST -H "content-type: application/json" -H "x-signature: $SIG" \
-d '{"type":"order.created","id":43}' http://127.0.0.1:8800/webhook
curl -s -X POST -H "content-type: application/json" \
-d '{"type":"order.created","id":42}' http://127.0.0.1:8800/webhook -w '\nunsigned -> %{http_code}\n'
Run against workerd, the three calls print exactly this — the first body is accepted, the tampered body fails on the signature rather than on a runtime error, and the unsigned request is rejected with the reason field that tells you the header was missing rather than wrong:
valid signature -> 200
body changed -> 401
{"error":"invalid_signature","reason":"malformed_header"}
unsigned -> 401
The full case matrix, driven over real HTTP against the same workerd instance, returns every status the handler claims to produce. A stale timestamp is rejected by the replay window, an oversized but correctly signed body is refused with 413, and a valid signature over a non-JSON payload gets the 400 rather than an unhandled exception:
{ status: 200, body: '{"received":true,"type":"order.created","id":42,"bytes":52}' }
{ status: 401, body: '{"error":"invalid_signature","reason":"signature_mismatch"}' }
{ status: 401, body: '{"error":"invalid_signature","reason":"malformed_header"}' }
{ status: 401, body: '{"error":"invalid_signature","reason":"stale_timestamp"}' }
{ status: 413, body: '{"error":"payload_too_large","limit":65536}' }
{ status: 400, body: '{"error":"invalid_json"}' }
{ status: 405, body: '{"error":"method_not_allowed"}' }
The same module, unchanged, produces the same seven results through worker.fetch(new Request(...)) on Node 26.5.1, which is the cheapest way to keep a Worker's body logic under unit test without a running runtime. The only reason to prefer the workerd harness is that it is the engine your code actually ships to.
The re-serialisation trap that breaks signatures
Verifying a signature over JSON.stringify(JSON.parse(body)) instead of over body is the most common way to end up with a handler that rejects perfectly valid webhooks — one in ten, unpredictably. The bytes are what was signed, and re-serialising changes them: key order is not guaranteed to survive, whitespace is not preserved, and any formatting option rewrites the payload.
I measured it rather than asserting it. Signing the compact payload {"b":2,"a":1} and then signing its pretty-printed equivalent gives two completely different HMACs for the same JSON value:
original bytes : {"b":2,"a":1}
HMAC(original) : a46bfbb2ec0f1f2ab5c70bad1e69587b9a43715468dd34e9463c1501c60277dd
parsed + re-stringify: {
"b": 2,
"a": 1
}
HMAC(re-serialised) : 2765800ab6b5fac1c647235c8d19f586841d47c148c4f72cf7709298636f74cc
One honest nuance from the same run: a compact round-trip — JSON.stringify(JSON.parse(body)) with no formatting and no key reordering — happened to produce the identical hash above, because V8 preserved insertion order and emitted compact output. That is the worst kind of bug: the code path looks correct in testing and fails on the first sender that orders its keys differently or sends whitespace. Verify the bytes you received, and the question never comes up.
If you use Hono: accessors cache, c.req.raw does not
Hono is where this error gets misdiagnosed most often, because Hono's own request accessors are cached. In Hono 4.13.7, c.req.json(), c.req.text() and c.req.arrayBuffer() all share one body cache, so a middleware that parses the body and a handler that parses it again both succeed. I verified that on Node 26.5.1: middleware plus handler both returned the parsed object with status 200.
Touching c.req.raw is what breaks it, because the raw Fetch Request is not part of that cache. Reading c.req.raw.text() in middleware and then calling c.req.json() in the handler reproduces the undici error exactly:
case 1 raw.text() then req.json() -> 500 {"ok":false,"error":"TypeError: Body is unusable: Body has already been read"}
case 2 req.text() then req.json() -> 200 {"ok":true,"parsed":{"event":"order.created","id":42},"bytesSeen":33}
case 3 raw.text() then raw.clone() -> 500 {"ok":false,"error":"TypeError: unusable"}
case 4 raw.clone().arrayBuffer() + req.json() -> 200 {"ok":true,"rawBytes":33,"parsed":{"event":"order.created","id":42}}
Case 4 works, but it is not the pattern I would ship for an unauthenticated endpoint: arrayBuffer() on a raw request buffers whatever the caller sends, and a clone() on top of it tees a second unread copy. Hono ships the middleware that closes that gap — bodyLimit — and it composes with the accessor cache, so the handler can still read the bytes through c.req.arrayBuffer() and a later c.req.json() still resolves. I ran all four cases against Hono 4.13.7: the ceiling fires on a buffered body and on a chunked stream alike, and a guarded parse returns the 400 instead of an uncaught TypeError.
A small body treated as JSON -> 200 {"received":true,"type":"order.created","bytes":32,"secondRead":"order.created"}
B 70 KiB body -> 413 {"error":"payload_too_large","limit":65536}
C 70 KiB chunked stream -> 413 {"error":"payload_too_large","limit":65536}
D small body that is not JSON -> 400 {"error":"invalid_json"}
Cases B and C were both enforced by the streaming branch: when I build a Request in Node with a string body, no content-length header is attached, so bodyLimit falls through to the reader that counts bytes as they arrive. The same middleware takes the cheap path when a real client sends the header, and both paths end in the same 413.
The secondRead field in case A is the important one: it comes from a second Hono accessor reading the body after the middleware already consumed the stream, which is the whole reason to prefer this over hand-rolling a reader on c.req.raw.
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
const MAX_BODY_BYTES = 64 * 1024;
const app = new Hono<{ Bindings: { WEBHOOK_SECRET: string } }>();
app.post(
'/webhook',
bodyLimit({
maxSize: MAX_BODY_BYTES,
onError: (c) => c.json({ error: 'payload_too_large', limit: MAX_BODY_BYTES }, 413),
}),
async (c) => {
const secret = c.env.WEBHOOK_SECRET;
if (!secret) return c.json({ error: 'not_configured' }, 500);
// Hono caches accessor reads: a later c.req.json() in this handler still resolves.
const raw = new Uint8Array(await c.req.arrayBuffer());
const verdict = await verifySignature(secret, raw, c.req.header('x-signature') ?? '');
if (!verdict.ok) return c.json({ error: 'invalid_signature', reason: verdict.reason }, 401);
let event: { type?: string };
try {
event = JSON.parse(new TextDecoder().decode(raw));
} catch {
return c.json({ error: 'invalid_json' }, 400);
}
return c.json({ received: true, type: event.type ?? null, bytes: raw.byteLength });
},
);
export default app;
Hono does guard one path of this itself: its cache middleware calls an internal clone helper when the body is still available, and when it is not, it raises Cannot clone request: body was already consumed and not cached. Please use HonoRequest methods (e.g., req.json(), req.text()) instead of consuming req.raw directly. I read that message in Hono 4.13.7's source rather than reproducing it — the middleware short-circuits in Node where the caches API does not exist — but the guidance in it is the same rule as everywhere else in this article: pick one consumer, and stay inside the API that owns it.
Checklist before you ship anything that reads a body
- One consumer per request. Grep the handler for
.json(),.text(),.formData(),.arrayBuffer(),.bodyand.clone(); if two of them are on the same object, you have the bug. - Clone before the first read, never after. A clone taken afterwards throws
currently locked to a reader, and a clone taken lazily "just in case" is where the retry loop will eventually bite. - Buffer once, pass the buffer. Verify signatures and compute hashes over the bytes you read, and let downstream code parse from that same buffer rather than from the request.
- Cap the size while streaming.
Content-Lengthis a hint from the client; enforce the ceiling in the read loop and cancel the stream when it is crossed. - Constant-time compare.
crypto.subtle.verify()for HMAC, not a string or hex comparison. - Expect the error text to differ by runtime. A handler tested on Node and deployed to workerd will produce unfamiliar strings for the same mistake — keep the table above next to your logs, and remember that an uncaught one becomes a 1101 for the whole request.
- Budget for buffering. Reading 64 KiB before doing anything costs CPU against the per-request limit, so parse once and cache the parsed result rather than re-parsing per branch — the CPU time limit is a real ceiling, not a theoretical one.
Related work on this blog
- Cloudflare Error 1042: worker fetch to the same zone fails — the forwarding path where a rebuilt Request costs you the body.
- Cloudflare Error 1101: Worker threw exception — what an uncaught TypeError here looks like from the caller's side.
- Cloudflare Error 1102: CPU time limit exceeded — why buffering and parsing budgets matter in the same handler.
All strings, status codes and digests in this article were produced by execution on 2026-09-13: workerd 2026-09-11 (compatibility date 2026-09-01), Node v26.5.1, Hono 4.13.7, driven over real HTTP on 127.0.0.1. The list of operations that disturb a Fetch body is quoted from Cloudflare's engineering answer on the Workers community forum; everything else quoted is program output.