"Cannot perform I/O on behalf of a different request" fix
Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler. means your Worker touched an object that belongs to another invocation. Cloudflare Workers gives every request its own I/O context; a stream, a Request, or a subrequest body captured in one context and dereferenced in another is refused by the runtime — not by your code, and not by a timeout. The guard fires at the moment of access: in the runs below it took 1.1–3.7 ms and returned HTTP 500 before a single byte of body data moved.
I reproduced every shape of it on workerd 2026-09-15, the open-source engine behind wrangler dev and the same runtime that executes deployed Workers, on an arm64 Linux 6.17 host with Node 26.5.1 and compatibilityDate = "2026-09-01". Three different I/O objects produce the identical message, a Durable Object produces a second wording, and one variant of the same mistake produces no message at all — it just never answers. All three outcomes are in the transcript below.
TL;DR — the ownership rule in one screen
- Cause: an I/O object created inside one request is stored where the next request can see it — a module-scope (global) variable — and dereferenced there.
- What counts as an I/O object:
request.body(the stream), the wholeRequestobject, the body of a subrequest response, aReadableStream/WritableStream, and the streams insideFormDatafile parts. - What is safe to share: plain data. A string, a parsed JSON object, a number, a
Uint8Arrayyou already consumed — these carry no I/O ownership. - Measured response shape: HTTP
500with the bodyInternal Server Error. The actual message appears only in the Worker log (wrangler taillocallyworkerd serve --verbose), on anuncaught exceptionline. - Durable Object wording is different:
Cannot perform I/O on behalf of a different Durable Object— even when the object was created by a plain stateless request in the same isolate. - Silent twin: a module-scope promise that wraps a
fetch()never settles for the later request. Measured: 6 s with zero bytes, no exception, no log line. That branch is covered in the "code had hung" post; here it is only the contrast case. - Fixes, in order of preference: cache data instead of I/O, keep the request alive with
ctx.waitUntil()when the work must outlive the response, and never storeRequest/Responseobjects at module scope.
| Stashed at module scope | Later access | Measured result |
|---|---|---|
request.body | stream.getReader().read() | HTTP 500, message in log, 2.6 ms |
the whole Request | stashed.text() | HTTP 500, same message, 1.1 ms |
subrequest response .body | new Response(s).text() | HTTP 500, same message, 3.7 ms |
request.body, read by a Durable Object | stream.getReader().read() | HTTP 500, Durable Object wording, 2.0 ms |
a pending promise wrapping fetch() | await stashedPromise | no response at all (hang) |
| the resolved JSON value | cachedData.n | HTTP 200 with data, 0.7 ms |
The rule behind the message: one I/O context per request
A Worker isolate serves many requests, so the runtime splits "the isolate" from "the request". Modules, imports and your top-level let declarations live per isolate and survive between requests; the I/O objects you receive — the incoming body, subrequest connections, sockets, streams — belong to a per-request IoContext that is torn down when the response is returned. In the workerd source this is the IoContext class, and the guard that produces this exact error is throwNotCurrentJsError() in src/workerd/io/io-context.c++, called from requireCurrentOrThrowJs() whenever an owned I/O object is dereferenced.
Two branches of that function exist, and the wording tells you which one you hit: io-context.c++:1719 emits the Durable Object sentence when the current context is an actor, and io-context.c++:1728 emits the request sentence otherwise. The runtime appends the concrete type it caught, which is how a message that looks generic ends with a very specific suffix — every stream case below ends with (I/O type: ReadableStreamSource).
That is the whole model. Everything else in this article is the same rule seen from a different angle: which object you stashed, and where the dereference happens.
Reproduce it: ten probes, one fresh server
The lab is three small files and one loop. config.capnp declares two services — the Worker under test and a tiny origin to fetch() from — and one socket:
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 = "COUNTER", durableObjectNamespace = "Counter"),
],
durableObjectNamespaces = [
(className = "Counter", uniqueKey = "ioctx-lab-20260930"),
],
durableObjectStorage = (inMemory = void),
)),
(name = "origin", worker = (
modules = [ (name = "origin.js", esModule = embed "origin.js") ],
compatibilityDate = "2026-09-01",
)),
],
sockets = [
(name = "http", address = "127.0.0.1:19930", http = (), service = "main"),
],
);
EOF
The origin answers two paths, /data immediately and /slow after 300 ms:
// origin.js — stand-in for the upstream you call from the Worker
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/data") {
return new Response(JSON.stringify({ region: "lab", n: 1 }), {
headers: { "content-type": "application/json" },
});
}
if (url.pathname === "/slow") {
await scheduler.wait(300);
return new Response(JSON.stringify({ region: "lab", slow: true }), {
headers: { "content-type": "application/json" },
});
}
return new Response("origin: not found\n", { status: 404 });
},
};
And the Worker under test does the one thing that is forbidden, six ways. Module scope holds the stash; the handlers only differ in what they put there:
// worker.js — module scope is per-isolate, NOT per-request.
let leakedBodyStream = null; // request.body, never consumed by the seeding request
let leakedRequest = null; // the whole Request object
let leakedSubrequestBody = null; // body of a subrequest response
let leakedDoStream = null; // read later by a Durable Object
let cachedPromise = null; // a promise wrapping I/O, stashed unresolved
let cachedData = null; // resolved plain data (the fix)
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
switch (url.pathname) {
// ---- the mistake: keep an I/O object from this request at module scope ---
case "/seed/body-stream": {
leakedBodyStream = request.body;
return new Response("seeded: request.body\n");
}
case "/seed/request": {
leakedRequest = request;
return new Response("seeded: Request\n");
}
case "/seed/subrequest-body": {
const upstream = await env.ORIGIN.fetch("http://origin/data");
leakedSubrequestBody = upstream.body;
return new Response("seeded: subrequest body\n");
}
case "/seed/do-stream": {
leakedDoStream = request.body;
return new Response("seeded: request.body for the DO\n");
}
case "/seed/promise": {
cachedPromise = (async () => {
const r = await env.ORIGIN.fetch("http://origin/data");
return r.json();
})();
return new Response("seeded: pending promise\n");
}
case "/seed/data": {
// The fix: resolve the I/O inside the request, cache the VALUE.
const r = await env.ORIGIN.fetch("http://origin/data");
cachedData = await r.json();
return new Response("seeded: resolved data\n");
}
case "/seed/promise-waituntil": {
// The fix for the pending promise: keep the request alive.
const p = (async () => {
const r = await env.ORIGIN.fetch("http://origin/data");
return r.json();
})();
cachedPromise = p;
ctx.waitUntil(p);
return new Response("seeded: promise + waitUntil\n");
}
// ---- the failures: use them from a later request -------------------------
case "/use/body-stream": {
const { value } = await leakedBodyStream.getReader().read();
return new Response(`read ${value.length} bytes\n`);
}
case "/use/request": {
const text = await leakedRequest.text();
return new Response(`request text: ${text}\n`);
}
case "/use/subrequest-body": {
const text = await new Response(leakedSubrequestBody).text();
return new Response(`upstream body: ${text}\n`);
}
case "/use/promise": {
const data = await cachedPromise;
return new Response(`promise data: ${JSON.stringify(data)}\n`);
}
case "/use/data": {
return new Response(`cached data: ${JSON.stringify(cachedData)}\n`);
}
// ---- the Durable Object branch ------------------------------------------
case "/do/use": {
const stub = env.COUNTER.get(env.COUNTER.idFromName("alpha"));
return stub.fetch("http://do/use");
}
default:
return new Response("lab worker: unknown path\n", { status: 404 });
}
},
};
export class Counter {
async fetch(request) {
// Same isolate as the stateless handler above, so it sees the same module scope.
const { value } = await leakedDoStream.getReader().read();
return new Response(`do read ${value.length} bytes\n`);
}
}
The runner below starts a fresh workerd per phase (module state persists for the life of the isolate — that is the point), drives the requests, and then prints the uncaught-exception lines from the log. It installs nothing: npm install workerd gives you the binary at node_modules/.bin/workerd.
#!/bin/sh
# Reproduce every shape of "Cannot perform I/O on behalf of a different request"
# on the real runtime. Requires config.capnp + worker.js + origin.js in the cwd.
set -u
WORKERD=${WORKERD:-./node_modules/.bin/workerd}
PORT=${PORT:-19930}
BASE="http://127.0.0.1:$PORT"
start_server() {
"$WORKERD" serve config.capnp --verbose > "$1" 2>&1 &
pid=$!
i=0
while [ "$i" -lt 40 ]; do
if curl -s -o /dev/null --max-time 2 "$BASE/__ready" 2>/dev/null; then return 0; fi
i=$((i + 1))
sleep 0.5
done
echo "!! workerd did not come up on $PORT (is another lab already bound there?)"
cat "$1"
return 1
}
probe() {
printf '\n=== %s\n' "$1"
shift
curl -sS --max-time 6 -o body.txt -w 'http_code=%{http_code} time_total=%{time_total}s\n' "$@" || printf 'curl exit=%s\n' "$?"
printf -- '--- response body:\n'
head -c 200 body.txt
printf '\n'
}
echo "### phase 1: one fresh server, ten probes"
start_server phase1.log || exit 1
probe "seed: stash request.body at module scope" -X POST --data 'hello-io-body' "$BASE/seed/body-stream"
probe "use: read that stream from the next request" "$BASE/use/body-stream"
probe "seed: stash the whole Request object" -X POST --data 'hello-req-body' "$BASE/seed/request"
probe "use: call .text() on it from the next request" "$BASE/use/request"
probe "seed: stash a subrequest response body" "$BASE/seed/subrequest-body"
probe "use: read that subrequest body later" "$BASE/use/subrequest-body"
probe "seed: stash a stream for the Durable Object" -X POST --data 'hello-do-body' "$BASE/seed/do-stream"
probe "use: the Durable Object reads it" "$BASE/do/use"
probe "seed: resolve the subrequest and cache the VALUE" "$BASE/seed/data"
probe "use: read the cached value" "$BASE/use/data"
kill -9 "$pid" 2>/dev/null
sleep 1
printf '\n=== phase 1 log: uncaught exceptions\n'
grep "uncaught exception; source = Uncaught" phase1.log | sed -e 's/^.*stack = //'
echo
echo "### phase 2: fresh server, the promise shapes"
start_server phase2.log || exit 1
probe "seed: stash an unresolved promise wrapping fetch()" -X POST "$BASE/seed/promise"
probe "use: await it from the next request (expect a hang)" "$BASE/use/promise"
probe "seed: same promise, but keep it alive with ctx.waitUntil()" "$BASE/seed/promise-waituntil"
probe "use: await it from the next request" "$BASE/use/promise"
kill -9 "$pid" 2>/dev/null
sleep 1
printf '\n=== phase 2 log: uncaught exceptions\n'
grep "uncaught exception; source = Uncaught" phase2.log | sed -e 's/^.*stack = //' || true
printf '\n### done\n'
Run it — WORKERD defaults to ./node_modules/.bin/workerd, the binary npm install workerd drops in place:
$ bash repro.sh
### phase 1: one fresh server, ten probes
=== seed: stash request.body at module scope
http_code=200 time_total=0.000704s
--- response body:
seeded: request.body
=== use: read that stream from the next request
http_code=500 time_total=0.002569s
--- response body:
Internal Server Error
=== seed: stash the whole Request object
http_code=200 time_total=0.002998s
--- response body:
seeded: Request
=== use: call .text() on it from the next request
http_code=500 time_total=0.001102s
--- response body:
Internal Server Error
=== seed: stash a subrequest response body
http_code=200 time_total=0.001280s
--- response body:
seeded: subrequest body
=== use: read that subrequest body later
http_code=500 time_total=0.003728s
--- response body:
Internal Server Error
=== seed: stash a stream for the Durable Object
http_code=200 time_total=0.000927s
--- response body:
seeded: request.body for the DO
=== use: the Durable Object reads it
http_code=500 time_total=0.001950s
--- response body:
Internal Server Error
=== seed: resolve the subrequest and cache the VALUE
http_code=200 time_total=0.001077s
--- response body:
seeded: resolved data
=== use: read the cached value
http_code=200 time_total=0.000690s
--- response body:
cached data: {"region":"lab","n":1}
repro.sh: line 47: 1699226 Killed "$WORKERD" serve config.capnp --verbose > "$1" 2>&1
=== phase 1 log: uncaught exceptions
Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: ReadableStreamSource)
Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: ReadableStreamSource)
Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: ReadableStreamSource)
Error: Cannot perform I/O on behalf of a different Durable Object. I/O objects (such as streams, request/response bodies, and others) created in the context of one Durable Object cannot be accessed from a different Durable Object in the same isolate. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: ReadableStreamSource)
Error: Cannot perform I/O on behalf of a different Durable Object. I/O objects (such as streams, request/response bodies, and others) created in the context of one Durable Object cannot be accessed from a different Durable Object in the same isolate. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: ReadableStreamSource)
### phase 2: fresh server, the promise shapes
=== seed: stash an unresolved promise wrapping fetch()
http_code=200 time_total=0.002096s
--- response body:
seeded: pending promise
=== use: await it from the next request (expect a hang)
curl: (28) Operation timed out after 6002 milliseconds with 0 bytes received
http_code=000 time_total=6.002680s
curl exit=28
--- response body:
seeded: pending promise
=== seed: same promise, but keep it alive with ctx.waitUntil()
http_code=200 time_total=0.000971s
--- response body:
seeded: promise + waitUntil
=== use: await it from the next request
http_code=200 time_total=0.000646s
--- response body:
promise data: {"region":"lab","n":1}
repro.sh: line 61: 1699254 Killed "$WORKERD" serve config.capnp --verbose > "$1" 2>&1
=== phase 2 log: uncaught exceptions
### done
Three observations from that transcript are worth more than the error text itself. First, all four failures are immediate: 1.1–3.7 ms including the curl round trip, which is what "refused at the point of access" looks like on the wire. Second, the request that seeds the stash always succeeds — the mistake is invisible until the second request, which is why this bug tends to appear under load and not in a single manual test. Third, the fix line at the end (cached data: {"region":"lab","n":1}) is the same upstream call, resolved inside the request and stored as plain JSON.
Why the client sees "Internal Server Error" and nothing else
Every one of those 500s carried the body Internal Server Error. The message you are searching for is not in the response; it is in the log, on an uncaught exception line, and it appears twice per failure with two different prefixes:
workerd/io/worker.c++:2585: info: uncaught exception; source = Uncaught (in promise); stack = Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: ReadableStreamSource)
at Object.fetch (worker.js:66:63)
workerd/io/io-context.c++:498: info: uncaught exception; exception = workerd/jsg/_virtual_includes/iterator/workerd/jsg/value.h:1477: failed: jsg.Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: ReadableStreamSource)
Two details in there change how you debug it. The first line gives you the JS stack — at Object.fetch (worker.js:66:44) is the dereference, not the stash, so it points at the reader; grep for the variable name from that line to find where it was written. The second line shows the JS type is jsg.Error: the runtime raises a plain Error, not a TypeError and not a DOMException, so a catch (e) { if (e instanceof TypeError) ... } handler will not classify it. Catch broadly, then match on the message.
To see these lines in production you need a live log tail — wrangler tail — or the Workers Logs view; locally, workerd serve --verbose (which is what the runner above uses). Without --verbose the exception lines still print, but the runtime's own warnings do not, and the warning path is exactly what you need for the silent variant of this bug.
The Durable Object wording, and the same-isolate trap
When the dereference happens inside a Durable Object, the runtime picks the other branch and the sentence changes to Cannot perform I/O on behalf of a different Durable Object — with the same three claims, an in the same isolate clause added, and the same (I/O type: ReadableStreamSource) suffix. This is worth pinning down, because it is easy to misread as "two Durable Objects passed something to each other".
In the run above the object was never touched by another Durable Object: a plain stateless request stashed request.body, and the DO's fetch() read it. The DO and the stateless handler share module scope, so the DO sees the stash, and by then the stateless context that created the stream is gone. The runtime's rule is about the context being current, not about which class of Worker owns it.
The practical consequence for DO code is the classic one: a Durable Object is the right place for cross-request state, but it must not reach into module-scope I/O that a plain request created. Keep DO state in state.storage and in the DO's own fields, and treat every module-scope variable as shared, mutable, and a candidate for this error.
The silent twin: a promise instead of a stream
The same mistake with a promise does not produce this error at all. Phase 2 above stashes the promise returned by an async IIFE that wraps a fetch(), and the later request that awaits it gets nothing: curl: (28) Operation timed out after 6006 milliseconds with 0 bytes received, HTTP code 000, and an empty log. No exception, no warning, no 500. The request simply never completes.
The reason is the same ownership rule: the promise's continuation needs the creating request's I/O context, and that context was destroyed when its response was returned, so the underlying fetch() cannot settle. On Cloudflare's edge that request is billed and eventually fails as a hung Worker — the failure mode documented in the "code had hung" analysis, which measured the same class of bug from the cancellation side and quoted the related runtime warning about a promise resolved from a different request context. If you are chasing a hang rather than a 500, read that post; if you are chasing this error string, you are in the right place.
The one-line fix in phase 2 is ctx.waitUntil(p): the request that creates the promise declares that the work must outlive the response, so the runtime keeps its context alive. Measured effect: http_code=200 time_total=0.000646s with the payload promise data: {"region":"lab","n":1}, where the unanchored version never answered.
The fixes, in the order I would apply them
1. Cache data, not I/O. This is the fix that removes the whole class of bug. Fetch, consume, then store the value:
case "/seed/data": {
// The fix: resolve the I/O inside the request, cache the VALUE.
const r = await env.ORIGIN.fetch("http://origin/data");
cachedData = await r.json();
return new Response("seeded: resolved data\n");
}
case "/use/data": {
return new Response(`cached data: ${JSON.stringify(cachedData)}\n`);
}
2. Anchor any promise that must outlive its request with ctx.waitUntil(). The promise stays legal because the context it belongs to stays alive; that is exactly what the handler context object is for. Note the difference between the two phase-2 seeds: identical promise, one line of difference, one hangs and one answers.
3. Never store Request or Response at module scope. The response object carries the same ownership as its body, and the Request case above failed on .text() even though the body had never been read.
4. Share a body only inside the request that owns it. Within one handler, request.clone() before the first read gives two independent bodies, and stream.tee() gives two branches of the same stream; both were measured in the "Body has already been used" post. The corresponding failure outside the owning request is what you are reading about here.
5. Audit for the shape. The stash always looks like an assignment of something request-shaped to a name that is not local. Two greps over the Worker source catch most of them:
$ grep -nE '^[[:space:]]*[A-Za-z_$][A-Za-z0-9_$]* = (request|request\.body|.*\.body);' worker.js
17: leakedBodyStream = request.body;
21: leakedRequest = request;
26: leakedSubrequestBody = upstream.body;
30: leakedDoStream = request.body;
Run the same pattern with let/var at column zero added, and you have a list of module-scope holders to review: a holder that is only ever assigned plain data is fine, a holder that is assigned a Request, a Response, a stream, or an unresolved promise is a 500 waiting for its second request.
What this error is not
Because the message is generic, it gets misattributed to four neighbours. None of them is this bug:
- Not the CPU limit. Error 1102 is about execution time, and it arrives after the CPU budget is spent, not in the first millisecond of a body access.
- Not an uncaught exception in general. Any uncaught throw inside a handler surfaces as Error 1101 to the client; the wording here is the discriminator, not the status class.
- Not the body-already-used family.
Body has already been used. It can only be used once.andThis ReadableStream is disturbedare about re-reading a body inside the owning request; this error is about crossing a request boundary. - Not the nodejs_compat module error.
No such module "node:buffer"is a compatibility-date problem and has nothing to do with I/O ownership.
Lab notes, if you want to reproduce this yourself
- Version matters. Measured on
workerd 2026-09-15withcompatibilityDate = "2026-09-01". The message text is produced by the runtime, so quote it from your own run before you grep a dashboard with it. - The exception lines come from the engine, the warnings need verbosity. Use
workerd serve config.capnp --verbose, otherwise you lose the warning channel you need for the promise variant. - Module state persists for the isolate's life. A single request proves nothing: the seeding request returns 200 and the second request fails. If your lab shows "no error", check that you actually re-sent a second request to the same server process.
- Bind collisions are the usual lab failure. A previous
workerdstill holding the port produceskj/async-io-unix.c++:941: failed: ::bind(...): Address already in usebefore any request runs. In my runs a plainkilldid not release the socket; the runner above useskill -9and the worker's own readiness loop prints a clear message instead of failing silently. - A Durable Object needs storage declared. Without
durableObjectStorage,workerd serverefuses to start the service:Worker service "main" implements durable object classes but has `durableObjectStorage` set to `none`.The config above uses(inMemory = void), which is enough for a local lab.
The whole diagnosis is one sentence: an isolate is shared, a request is not, and the runtime will not let you borrow a previous request's I/O. Cache the value, anchor the promise, and the error disappears along with the class of bug that produces it.