Skip to content

Revision 1 — the current text. The evidence on this page is this revision’s own — it has not been carried forward from, or to, any other revision.

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 curation

Symptoms

The diagnostic path

8 steps, exactly as this revision published them.

  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.

This counts only what was recorded against revision 1 itself. Nothing reported against another revision is included here — see the revision history for why.