>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

Cloudflare Workers: "Body has already been used" fix

Alex Rivera covers Cloudflare Workers and the serverless edge at C2CZ. Author page · All authors.

A Worker that reads the request body twice dies with TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice. The message is refreshingly honest, and it still cost me a Friday: the request body in a Workers HTTP handler is a single-use stream, so the moment an auth middleware calls await request.json() for a signature check, every handler behind it is reading an empty shell. What makes this worth a write-up is that the same root cause prints four different strings depending on where the second read happens, and only one of them mentions the body at all.

I rebuilt all four failure paths on the real engine — workerd, the same binary Cloudflare runs at the edge — and recorded the exact message each one emits, plus the three patterns that fix them. Everything quoted below is pasted from that run: workerd 2026-09-15, compatibility date 2026-09-01, Node v26.5.1, Linux 6.17. Where a string differs between the Workers runtime and Node, both are shown, because that gap is what makes the bug survive local tests.

TL;DR

  • A Request or Response body is a WHATWG ReadableStream. It can be consumed once. Reading it marks it used forever — there is no rewind, no .reset(), no cache.
  • Four distinct strings, one cause, all measured on workerd:
    • Second read of the same object → TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.
    • Forwarding an already-read request upstream with fetch(request) → TypeError: This ReadableStream is disturbed (has already been read from), and cannot be used as a body.
    • Calling request.clone() after any read → TypeError: This ReadableStream is currently locked to a reader, (yes, with the trailing comma — that is the runtime’s string, and it is a different message from the sibling disturbed error).
    • A second request.body.getReader() → TypeError: This ReadableStream is currently locked to a reader.
  • Fixes, in order of preference: clone before anything reads (const copy = request.clone()), request.body.tee() when both halves are needed at the same time and you cannot buffer, and a request.bodyUsed guard in shared logging middleware so it never consumes a body someone else owns.
  • request.bodyUsed is false until the first read and true after. Reading a clone leaves the original’s flag false — that asymmetry is what makes the guard safe.
  • GET and HEAD requests have request.body === null; await request.text() returns "", but request.body.getReader() throws TypeError: Cannot read properties of null (reading 'getReader').
  • Node prints different strings for the same mistakes (Body is unusable: Body has already been read, TypeError: unusable), and requires duplex: "half" for stream bodies. A green Vitest run in Node proves less than you think.
  • Related, already covered here: the sibling stream error where the body was read by a different consumer ("This ReadableStream is disturbed"), and the handler-shape failures in Handler does not export a fetch() function.

Why the body is a one-shot stream

A Workers handler receives a Request, and its body is exposed as request.body: a ReadableStream of Uint8Array chunks. The convenience methods — text(), json(), arrayBuffer(), formData() — are not properties you can read again; each one drains the stream to completion and resolves with the accumulated bytes.

That design is deliberate: it is why a Worker can stream a body straight into an outbound fetch() or into storage without holding the whole payload as a string. The cost is immutability: once bytes have left the stream, they are gone from the request object. Anything downstream that asks for them gets a runtime error instead of an empty body, which is the runtime doing you a favour — a silent empty body is far harder to debug than a TypeError with your file and line in it.

The practical consequence for application code is a rule that is easy to violate in a codebase with middleware: whoever reads the body decides whether everyone else can have it. A signature check that reads the body must either clone the request first, split the stream, or hand the unconsumed body to the next layer.

Reproduce it locally in four minutes, no Cloudflare account

You do not need to deploy to see any of this. workerd installs as one binary from npm, unauthenticated, and runs a Cap’n Proto config that points a socket at an ES module. That is the entire harness I used for every string in this article.

$ mkdir -p edge-lab && cd edge-lab
$ npm install workerd
$ ./node_modules/.bin/workerd --version
workerd 2026-09-15

Two field names in the config are worth a second look, because they are the usual first-run failures: the module must be declared as an ES module (esModule = embed "worker.js"), and the path inside embed is resolved relative to the config file, not to the directory you launched the binary from.

# config.capnp
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",
    )),
  ],
  sockets = [
    (name = "http", address = "127.0.0.1:19923", http = (), service = "main"),
  ],
);

Now a probe worker that catches the error and reports it as JSON, so every string below is machine-readable rather than retyped from a terminal screenshot. The probe() wrapper is the whole trick: it turns a thrown TypeError into a payload instead of a 500.

// worker.js — run with: workerd serve config.capnp
export default {
  async fetch(request) {
    const path = new URL(request.url).pathname;

    const probe = async (fn) => {
      try {
        return { error: null, value: await fn() };
      } catch (e) {
        return { error: { name: e.name, message: e.message }, value: null };
      }
    };

    switch (path) {
      case "/twice-text":
        return Response.json(await probe(async () => {
          const first = await request.text();
          const second = await request.text();   // <-- second read
          return { first, second };
        }));

      case "/clone-after-read":
        return Response.json(await probe(async () => {
          await request.text();
          const copy = request.clone();          // <-- clone too late
          return { cloneBody: await copy.text() };
        }));

      default:
        return new Response("no route", { status: 404 });
    }
  },
};

Cause 1: the second read — the middleware trap

This is the one that bites in production. A shared middleware — HMAC signature verification, audit logging, a body-size check — needs the raw bytes to do its job, so it reads them. The route handler that runs afterwards also needs the body. Nothing in either file looks wrong.

$ curl -s -X POST -H 'content-type: application/json' \
    --data '{"user":"ada","role":"admin"}' http://127.0.0.1:19923/twice-text
{"error":{"name":"TypeError","message":"Body has already been used. It can only be used once. Use tee() first if you need to read it twice."},"value":null}

Notice where it fails: the second reader, never the first. The middleware succeeded, the log line was written, and the exception surfaces inside your route handler — which is why the stack trace in wrangler tail points at the wrong file and people spend the first hour blaming the router.

The same message covers every consumption method, because they all drain the same stream. Measured on workerd, all four of these produce byte-identical output:

ProbeResult on workerd 2026-09-15
await request.text() twiceTypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.
await request.text() then await request.json()same message
await request.arrayBuffer() twicesame message
await new Response("{}").text() twice (a Response body behaves identically)same message
await request.formData() twice (urlencoded body)same message

One detour worth knowing, because it disguises itself as the same bug: formData() on a request whose Content-Type is application/json fails before touching the stream, with TypeError: Unrecognized Content-Type header value. FormData can only parse the following MIME types: multipart/form-data, application/x-www-form-urlencoded. If you see that string, the body was never consumed; you have a content-type bug, not a stream bug.

Cause 2: forwarding an already-read request upstream

The second shape is a proxy or gateway Worker: read the body to authenticate, then pass the request through to an origin. Here you get a completely different string, and it names neither Body nor tee():

$ curl -s -X POST -H 'content-type: application/json' \
    --data '{"user":"ada","role":"admin"}' http://127.0.0.1:19923/forward-after-read
{"error":{"name":"TypeError","message":"This ReadableStream is disturbed (has already been read from), and cannot be used as a body."},"value":null}

Disturbed is the spec’s word for a stream that has been read from at least once, and it is the term you will also meet when a body is handed to two consumers. The same underlying mistake therefore reaches you with two vocabularies — used from the convenience methods, disturbed from construction sites like fetch() and new Request() — plus a third, locked, from the stream API itself. If you are triaging from a log line, work backwards from the wording to the layer: the wording tells you exactly which call site rejected you.

The fix here is the same one-line rule as case 1, applied at the right moment: clone before the first read, forward the clone.

export default {
  async fetch(request, env) {
    // Snapshot the body for the outbound leg BEFORE reading anything.
    const upstreamRequest = request.clone();

    const raw = await request.text();           // this copy is consumed here…
    await verifyHmac(raw, env.WEBHOOK_SECRET);  // …and only here

    // …while the origin still receives an intact body.
    return env.ORIGIN.fetch("https://origin.internal/echo", upstreamRequest);
  },
};

Measured on the same harness, with a second workerd service standing in for the origin: the pre-cloned forward returns 200 and the origin reports the full 29 bytes of the payload. Reversing the two statements — clone after text() — reproduces the disturbed error above. Position is everything.

Cause 3: cloning after a read (and the trailing comma)

Everyone eventually writes the clone, and a good number of people write it one line too late. Cloning is not copying a cached buffer — it tees the stream at that instant, so a stream that is already locked cannot be cloned at all:

$ curl -s -X POST -H 'content-type: application/json' \
    --data '{"user":"ada","role":"admin"}' http://127.0.0.1:19923/clone-after-read
{"error":{"name":"TypeError","message":"This ReadableStream is currently locked to a reader,"},"value":null}

Read that string twice: it ends in a comma and nothing follows it. I re-ran the probe three times and captured the stack to be sure it was not my own string concatenation, because an error message with a dangling comma looks like a copy-paste mistake:

TypeError: This ReadableStream is currently locked to a reader,
    at worker.js:36:19
    at async probe (worker.js:5:42)
    at async Object.fetch (worker.js:34:30)

The stack confirms it is the runtime’s own message, so treat the comma as part of the string if you grep for it. Note also that this is a different error from the disturbed one: locked means a reader is attached right now, disturbed means the stream has already been read from. Both mean the same thing for your code — too late.

The locked flag itself is worth knowing, because it is the state your code checks before it decides anything. Measured on a single request: request.body.locked is false, then true while a reader is held, and it stays true after the read completes. It is a one-way door, not a busy indicator.

Cause 4: a second getReader(), and the GET trap

Code that manages the stream manually hits the same wall from the stream side. Two getReader() calls on one body throw TypeError: This ReadableStream is currently locked to a reader. — no comma this time, because the call site is different. The fix is structural: call getReader() once, keep the reader, and pass it around, or use tee() as shown below.

Then there is the variant that burns people in the other direction. GET and HEAD requests have no body at all, and the runtime tells you so precisely:

$ curl -s http://127.0.0.1:19923/read-get
{"error":null,"value":{"method":"GET","bodyIsNull":true,"value":""}}

$ curl -s http://127.0.0.1:19923/reader-twice -X GET
{"error":{"name":"TypeError","message":"Cannot read properties of null (reading 'getReader')"},"value":null}

So a logging middleware that does await request.text() unconditionally is harmless on GET (it gets ""), but the same middleware written against request.body.getReader() throws on every GET — including the CORS preflight OPTIONS your frontend depends on. Guard the body branch on request.body !== null, and test with a plain GET before you ship.

The fix that covers most code: clone before anything reads

If exactly one layer needs the raw bytes and the rest needs the parsed object, clone immediately on entry — before any await that touches the body. verifyHmac and route below stand in for your own functions; the ordering of the two statements is the part that matters.

export default {
  async fetch(request, env, ctx) {
    if (request.method === "POST" && request.headers.get("content-type")?.includes("application/json")) {
      // Clone on entry: two independent, fully readable bodies.
      const forSignature = request.clone();

      const raw = await forSignature.text();
      if (!(await verifyHmac(raw, env.WEBHOOK_SECRET))) {
        return Response.json({ error: "bad signature" }, {
          status: 401,
          headers: { "WWW-Authenticate": "HMAC", "X-Content-Type-Options": "nosniff" },
        });
      }
      // request itself is still unread and fully readable downstream.
    }

    return route(request, env, ctx);
  },
};

Measured on workerd: with the clone taken first, both bodies read in full (29 bytes each) and await clone.json() parses correctly alongside a plain-text read of the original. There is no ordering hazard, no partial-chunk case, and no size limit that the non-cloned path does not also have.

When both consumers need the body at the same time: tee()

Cloning is fine when one consumer reads immediately. It is the wrong tool when two consumers must proceed concurrently — a signature check streaming to one code path while the parsed payload feeds a queue, for instance. That is what tee() is for, and the runtime’s own error message points at it.

export default {
  async fetch(request, env) {
    // tee() splits the stream into two independent branches, no buffering.
    const [forSignature, forHandler] = request.body.tee();

    // Branch A: stream the raw bytes into the signature check.
    const signature = await new Response(forSignature).text();

    // Branch B: rebuild a Request so the handler sees the normal API.
    // workerd accepts a stream body here without any extra option.
    const routed = new Request(request, { body: forHandler });
    const payload = await routed.json();

    return Response.json({ bytes: signature.length, user: payload.user });
  },
};

Two measured details that matter when you adopt this. First, request.body.locked is true immediately after tee() — the original stream is now owned by the two branches, so never touch request.body again, only the halves. Second, new Request(request, { body: half }) works on workerd with no additional option; in Node/undici the same construction throws TypeError: RequestInit: duplex option is required when sending a body. and needs duplex: "half". Code that is correct on the edge can therefore fail in a Node test harness for a reason that has nothing to do with your logic.

The guard that makes shared middleware safe: request.bodyUsed

Middleware that runs on every route cannot assume it owns the body. The bodyUsed flag lets it check first, and the asymmetry below is what makes the check reliable: reading a clone does not mark the original as used.

async function logBodyPreview(request) {
  // Only touch the body if nobody has consumed it yet.
  const preview = request.bodyUsed ? null : await request.clone().text();
  return preview === null ? null : preview.slice(0, 256);
}

Measured states on a single request: request.bodyUsed is false on entry, true after any read, and after reading a clone the original still reports bodyUsed === false and still yields its full 29 bytes to the next consumer. That is exactly the property a preview logger needs — it observes the body without ever owning it.

Two caveats before you paste that helper everywhere. A clone tees the stream, so any chunk the clone’s branch has not read yet has to wait for it — do not clone a large upload just to log a preview; check the size first. And the guard is not a repair: if an upstream middleware already read the body, bodyUsed is true and the honest options are to fix that middleware or to share the body through tee() — there is no way to hand a consumed stream back.

Why your Node test suite does not catch this

Every failure above produced a different string under Node v26.5.1 on the same machine. Same mistakes, different words, and three of the four lose the diagnostic detail that makes the fix obvious:

Mistakeworkerd 2026-09-15 (edge)Node v26.5.1 (undici)
Two reads of the same requestBody has already been used. It can only be used once. Use tee() first if you need to read it twice.Body is unusable: Body has already been read
clone() after a readThis ReadableStream is currently locked to a reader,TypeError: unusable
fetch() with an already-read requestThis ReadableStream is disturbed (has already been read from), and cannot be used as a body.Cannot construct a Request with a Request object that has already been used.
new Request(request, { body: stream })succeeds (as shown above)RequestInit: duplex option is required when sending a body.

Node’s bare TypeError: unusable for a late clone is the one that makes a bug invisible: a test asserting toThrow(TypeError) passes locally and tells you nothing, and a log line with unusable in it will not match the string your workers are printing in production. Assert on the runtime, not on the language. If your tests run under Node, assert that the read succeeds in the fixed path -- a positive assertion survives both runtimes; a message assertion does not.

Verify the fix before you deploy

The harness above is the verification, and it takes one curl per branch. Start workerd against the config, then check the paths that matter: the normal request, the upstream forward, and a bodyless request. A pass looks like this — three JSON payloads with "error": null.

$ timeout 90 ./node_modules/.bin/workerd serve config.capnp &
$ curl -s -X POST -H 'content-type: application/json' \
    --data '{"user":"ada","role":"admin"}' http://127.0.0.1:19923/clone-before-read
{"error":null,"value":{"original":"{\"user\":\"ada\",\"role\":\"admin\"}","clone":"{\"user\":\"ada\",\"role\":\"admin\"}"}}
$ curl -s -X POST -H 'content-type: application/json' \
    --data '{"user":"ada","role":"admin"}' http://127.0.0.1:19923/forward-clone
{"error":null,"value":{"origin":true,"path":"/echo","bytes":29}}
$ curl -s http://127.0.0.1:19923/read-get
{"error":null,"value":{"method":"GET","bodyIsNull":true,"value":""}}

Two operational notes about that harness. Run it on a port that is actually free — if another process is already listening there, every probe talks to that process and the results are quietly meaningless. And kill the server with the PID you started, not with a broad pkill -f workerd, because a pattern match can also hit the shell running it.

Once deployed, wrangler tail is the confirmation channel: a fix that still throws shows the same three strings, verbatim, in that stream. The absence of those lines across a normal traffic window is the evidence you want, not a 200 on one hand-rolled request.

Checklist

  • Take the clone on entry. const copy = request.clone(); as the first statement of the middleware that needs raw bytes — before any await that reads the body.
  • Never clone after reading. A late clone() throws ...locked to a reader, and the trailing comma is the runtime’s, not a typo in your log.
  • Prefer tee() over clone() for concurrent consumers. Two branches, no buffering, but the original stream is locked from that moment — use only the halves.
  • Guard shared middleware with request.bodyUsed. Reading a clone does not mark the original used, so a preview logger never steals the body from the handler.
  • Guard the body branch on request.body !== null. GET/HEAD/OPTIONS requests have a null body; text() returns "", getReader() throws.
  • Do not trust a Node-only test for body semantics. Both strings and stream-body requirements differ; assert that the readable path works rather than asserting on a message.
  • Cap what you clone. A clone buffers the body for the duration of the read — check Content-Length before previewing an upload.

Method note: every string in this article was produced on workerd 2026-09-15 (compatibility date 2026-09-01) and Node v26.5.1 on Linux 6.17, using the harness shown above; the origin leg was a second workerd service bound as ORIGIN. The runtime’s messages are implementation detail and may change between builds — re-run the probe after upgrading rather than trusting a quote, including mine.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.