Fixing "Cannot perform I/O on behalf of a different request"
Covers the module-level-caching anti-pattern for I/O objects (Request/Response/streams/database clients) in Workers. Does not cover the Cache API, which is the correct, supported way to cache actual HTTP responses across requests — this playbook is specifically about accidentally reusing the live object rather than its data.
Uncaught (in promise) Error: Cannot perform I/O on behalf of a different request
The error appears only on the second (or a later) request, never the first
A Response, Request, ReadableStream, or database client is assigned to a variable declared outside the fetch handler
The diagnostic path
8 steps. Every step is written out below in full — the interactive version simply follows the branches for you.
Step 1 · StartWorker throws "Cannot perform I/O on behalf of a different request"
Each Worker invocation has its own isolated execution context. Accessing a stream, Request, or Response body created in one invocation from a different invocation is disallowed and throws this error — it's the runtime protecting request isolation, not a transient failure.
What happens next
passed → step 2, Look for an I/O object cached at module scope
Step 2 · TestLook for an I/O object cached at module scope
Search for a Request, Response, ReadableStream, or similar object assigned to a variable declared outside the fetch handler, then read again on a later invocation.
Read-onlysh
grep -n "^let \|^const \|^var " src/index.ts
Expected result
A top-level variable such as `let cachedResponse = null;` that gets assigned inside fetch() and read on a subsequent call.
What happens next
passed → step 3, Cache the data, not the I/O object
failed → step 4, Check for a cached database/connection client
unknown → step 3, Cache the data, not the I/O object
Step 3 · FixCache the data, not the I/O object
Extract and store the plain data (a string, parsed JSON, an ArrayBuffer) instead of the live Response/Request/stream. Construct a fresh Response from that data on each invocation. If the goal is genuine HTTP response caching across requests, use the Cache API instead, which is designed for exactly that.
Read-onlyts
let cachedData = null; // plain data, not a Response
export default {
async fetch(request, env, ctx) {
if (cachedData) return new Response(cachedData);
const res = await fetch("https://example.com/data");
cachedData = await res.text();
return new Response(cachedData);
},
};
What happens next
passed → step 5, Send two rapid successive requests
Step 4 · TestCheck for a cached database/connection client
The same error shows up when a database client — a Hyperdrive/pg connection, a driver instance — is created once at module scope and reused across invocations instead of being created fresh inside the handler.
Read-onlysh
grep -n "new Client\|new Pool\|connect(" src/**/*.ts
Expected result
A client/connection constructed at module scope, outside fetch(), and reused across requests.
What happens next
passed → step 6, Create the client inside the handler, per request
failed → step 7, No cached I/O object found — escalate
unknown → step 7, No cached I/O object found — escalate
Step 5 · Verify the fixSend two rapid successive requests
Fire two requests back to back and confirm neither throws, and that the second reflects fresh data rather than a stale cached I/O object.
Both requests return normally with no "Cannot perform I/O" error.
What happens next
passed → step 8, Resolved
failed → step 7, No cached I/O object found — escalate
unknown → step 7, No cached I/O object found — escalate
Step 6 · FixCreate the client inside the handler, per request
Move client construction inside the request handler so each invocation gets its own. Hyperdrive's connection pooling already removes the connection-startup cost this pattern was usually trying to avoid, so there's no performance reason to hand-roll caching here.
passed → step 5, Send two rapid successive requests
Step 7 · EndNo cached I/O object found — escalate
If nothing at module scope holds an I/O object, look for the same pattern one level removed — an object stored in a class instance that itself persists across invocations (e.g. attached to a Durable Object instance field, which does persist between requests to that object).
Step 8 · EndResolved
I/O objects are now created fresh per invocation rather than shared across requests.