Skip to content

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.

Unverifiedno reproductions yetWhy this confidence?
Revision 1published by DevYou curationrevision history
Run the diagnosisEvidence and compatibility

Symptoms

The diagnostic path

8 steps. Every step is written out below in full — the interactive version simply follows the branches for you.

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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.

    Read-onlysh
    curl -s https://your-worker.example.workers.dev/ && curl -s https://your-worker.example.workers.dev/

    Expected result

    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
  6. 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.

    Read-onlyts
    export default {
      async fetch(request, env) {
        const client = new Client({ connectionString: env.HYPERDRIVE.connectionString });
        await client.connect();
        try {
          return Response.json(await client.query("SELECT 1"));
        } finally {
          ctx.waitUntil(client.end());
        }
      },
    };

    What happens next

    • passed step 5, Send two rapid successive requests
  7. 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).

  8. Step 8 · EndResolved

    I/O objects are now created fresh per invocation rather than shared across requests.

Sources

Why this confidence?

What would strengthen it: 6 more independent reproductions. Reproductions from 3 more distinct environments.