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

Cloudflare Workers: "code had hung" Error 1101 fix

Error: The Workers runtime canceled this request because it detected that your Worker's code had hung and would never generate a response. is not a timeout and it is not a slow origin. The runtime emits it the moment it can prove that the request's handler will never return a Response — and the most common way to build that situation is a promise that one request created and a different request awaited.

I reproduced both shapes of that mistake in workerd 2026-09-15, the engine behind wrangler dev, on an arm64 host with Node 26.5.1. A module-scope barrier promise that another request resolves is refused in 1.2 ms with an HTTP 500 and two distinct log lines — one of them names the file and line of the resolve call. A cached in-flight fetch() awaited by the next request just hangs: 45 s with no response and, at default log verbosity, no diagnostic at all. In production the first of those reaches the user as Error 1101; the second never answers.

Every number and every quoted log line below comes from those runs on 2026-09-15. If you are here because production is down, jump to the grep list — it is four patterns, and one of them is almost certainly your bug.

TL;DR

  • The cancellation is proof-based, not timer-based. Cloudflare's wording is that it happens when "all the code associated with the request has executed and no events are left in the event loop, but a Response has not been returned". Measured: 1.2–1.6 ms across four runs (1.204, 1.215, 1.258, 1.647 ms). Nothing here is waiting 30 s for an origin.
  • A promise resolved by another request gives you a log line that names the culprit. Warning: A promise was resolved or rejected from a different request context ... followed by at Object.fetch (worker.js:26:7) — line 26 was the resolveReady("go") call, inside the request that resolved it.
  • A cached in-flight fetch() is the silent version of the same bug. The awaiting request never gets an answer (measured ≥45 s) and workerd says nothing about it at default verbosity. That is the one that looks like a hung origin in your dashboard.
  • Promises belong to the request that created them. Plain data does not. Cache the resolved value, report state instead of awaiting another invocation's promise, and reach for Durable Objects or Workers KV when you need real cross-request coordination.
  • The compatibility flag the warning suggests removes the warning, not the failure. With no_handle_cross_request_promise_resolution set, the awaiting request still returned 500 in 1.2 ms, and the resolving side then threw Cannot perform I/O on behalf of a different request.
  • You can reproduce all of it with no Cloudflare account. workerd ships these exact strings; the lab below is one Cap'n Proto config and one file you swap between broken and fixed.

On this page

The runtime cancels on proof, not on a timer

Cloudflare groups this failure under The "script will never generate a response" errors in the Workers errors reference, and the definition is precise: it happens when the runtime detects that all the code associated with the request has executed, no events are left in the event loop, and a Response has still not been returned. Two causes are documented there — a promise that is never resolved or rejected, and a WebSocket whose server-side connection is never closed.

Both are worth knowing, but neither is what I kept finding in real repositories. The pattern that bites is a promise that is perfectly well-behaved on its own: something resolves it, nothing is leaked, no socket is left open. The only wrong decision is which request holds it. Workers isolates each invocation in its own I/O context, so a promise that wraps I/O — or a promise that another invocation resolves — cannot be handed to a later request and expected to work.

The two shapes fail visibly differently, which is why the bug survives code review and shows up as "the API call hangs sometimes":

What is shared across invocationsWhat the caller seesWhat the runtime logs
A promise resolved by a different request (barrier, lock, "ready" gate)HTTP 500 in ~1.2 ms (edge: Error 1101)The code had hung error, plus a warning naming the resolve call's file and line
An in-flight fetch() cached at module scope and awaited by later requestsNo response at all — locally ≥45 s, then the client gives upNothing at default verbosity; the cancellation text only appears with --verbose

Both rows are measurements from the lab below, not paraphrases of the documentation. The distinction matters for diagnosis: if your logs are silent and requests simply never finish, you are looking at the second row, and every request that awaits the poisoned promise will hang rather than fail fast.

Reproducing it with workerd in two minutes

You do not need a Cloudflare account, a tunnel or a deploy. workerd is the actual runtime — the same binary wrangler dev and Miniflare start under the hood — and the strings quoted in this article are present in that binary, which is why it can reproduce the edge behaviour faithfully.

Prerequisites: Node 20 or newer, and a directory you are happy to install into. The version below is the one this article was measured on; workerd's version numbers are date-stamped, so re-running today may print a later date and still behave the same way.

mkdir -p /tmp/wd-hang && cd /tmp/wd-hang
npm install workerd
./node_modules/.bin/workerd --version
# workerd 2026-09-15

The configuration defines two services: main, which is the Worker under test, and origin, a stand-in for whatever upstream API your real Worker fetches. Two details are load-bearing. The ORIGIN service binding is what lets the Worker make a subrequest at all — without it, the first fetch throws Cannot read properties of undefined (reading 'fetch') and you will spend ten minutes blaming the wrong thing. And the config embeds worker.js by name, so swapping the Worker means overwriting that one file.

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 = "origin", worker = (
      modules = [ (name = "origin.js", esModule = embed "origin.js") ],
      compatibilityDate = "2026-09-01",
    )),
  ],
  sockets = [
    (name = "http", address = "127.0.0.1:8790", http = (), service = "main"),
  ],
);
EOF

The origin answers /token after a 300 ms delay and counts how many times it was actually hit, so the fixed version later in this article can prove it served a request from cache rather than from a second network call.

// origin.js — stand-in for the upstream API; `issued` counts real hits.
let issued = 0;

export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === "/token") {
      await new Promise((resolve) => setTimeout(resolve, 300));
      issued += 1;
      return Response.json({ token: `tok_${issued}`, issued });
    }
    return new Response("origin: not found", { status: 404 });
  },
};

Now the broken Worker. This is the smallest honest version of a pattern I have seen in three production codebases: a module-scope promise used as a barrier, so that callers can await "the system is ready" instead of polling. One request waits on it, another request resolves it. Note that nothing is awaited at module scope and no request is left dangling on purpose — every line looks reasonable in isolation.

// worker.js — BROKEN: a module-scope barrier promise.
// Request A awaits it; request B (a webhook / admin poke) resolves it.
let ready = null;
let resolveReady = null;

function barrier() {
  if (!ready) {
    ready = new Promise((resolve) => {
      resolveReady = resolve;
    });
  }
  return ready;
}

export default {
  async fetch(request) {
    const path = new URL(request.url).pathname;

    if (path === "/await-ready") {
      await barrier();
      return new Response("ready\n");
    }

    if (path === "/signal") {
      barrier();
      resolveReady("go");
      return new Response("signalled\n");
    }

    return new Response("not found", { status: 404 });
  },
};

Start it with --verbose, because the interesting line is a warning and the default log level hides it:

./node_modules/.bin/workerd serve --verbose config.capnp

In a second shell, call the endpoint that waits, then the one that signals. The waiting request does not wait: it is refused immediately, before the signal is ever sent.

curl -s -o body.txt -w "await_ready=%{http_code} t=%{time_total}\n" http://127.0.0.1:8790/await-ready
# await_ready=500 t=0.001258
cat body.txt
# Internal Server Error

curl -s -o /dev/null -w "signal=%{http_code} t=%{time_total}\n" http://127.0.0.1:8790/signal
# signal=200 t=0.000704

One point four milliseconds to be refused, and the request that "caused" it had not even arrived yet. That is the whole diagnosis: the runtime does not wait to see whether your promise ever resolves — it inspects the request's event loop, finds nothing that could produce a Response, and cancels.

The runtime's own output for those two requests, with the binary stack addresses trimmed, is four lines that tell you everything:

workerd/io/io-context.c++:498: info: uncaught exception; exception = workerd/io/io-context.c++:1684: failed: jsg.Error: The Workers runtime canceled this request because it detected that your Worker's code had hung and would never generate a response. Refer to: https://developers.cloudflare.com/workers/observability/errors/
workerd/server/server.c++:6643: error: Uncaught exception: workerd/io/io-context.c++:1684: failed: remote.jsg.Error: The Workers runtime canceled this request because it detected that your Worker's code had hung and would never generate a response. Refer to: https://developers.cloudflare.com/workers/observability/errors/
Warning: A promise was resolved or rejected from a different request context than the one it was created in. However, the creating request has already been completed or canceled. Continuations for that request are unlikely to run safely and have been canceled. If this behavior breaks your worker, consider setting the `no_handle_cross_request_promise_resolution` compatibility flag for your worker.
    at <anonymous>
    at Object.fetch (worker.js:26:7)

What the two log lines actually tell you

The first two lines are the cancellation itself. io-context.c++:1684 is runtime source, not your code — it tells you the request's I/O context was torn down while a handler was still executing, and the message is the runtime's explanation of why it gave up on producing a response. In production you will not see this text through curl; the client gets an Error 1101 page, and the message lands in Workers Logs, where the documented filters are $metadata.error EXISTS to find anything with an error attached and $workers.outcome = "exception" to isolate uncaught exceptions. wrangler tail shows the same thing live under the exceptions field.

The third line is the one worth building a habit around. It is not about the request that hung — it is about the request that resolved a promise belonging to another invocation's context, and it ends with the source location of that resolution: worker.js:26:7, which in the listing above is the resolveReady("go") call. When this warning appears in your logs, the file and line it reports is where the shared promise was resolved, and the promise's creation site is a few lines away in the same module.

Do not over-read it either. In an earlier variant of this lab — two endpoints, one creating the promise and another resolving it, with nothing awaiting it at all — the same warning fired at the resolve call. That is the general rule, and a useful one: any promise resolved from a request context other than the one that created it draws this warning, whether or not a request is currently blocked on it. It is a diagnostic about aliasing, not about the specific hang.

The other shape: the in-flight fetch you cached on purpose

The barrier above is a little artificial — people write it, but most production hits of this error come from a cache. The intent is good: /token is expensive, so the Worker memoises it. The mistake is memoising the promise rather than the value, because a promise that wraps a fetch() is an I/O handle owned by the request that created it.

// worker.js — BROKEN: the in-flight fetch promise is cached at module scope.
let tokenPromise = null;
let token = null;

export default {
  async fetch(request, env) {
    const path = new URL(request.url).pathname;

    // "Warm the token cache on the first hit" — answer now, keep the promise
    // for later requests. No await, no ctx.waitUntil().
    if (path === "/warm") {
      tokenPromise = env.ORIGIN.fetch("https://origin.internal/token").then((r) => r.json());
      return new Response("warming", { status: 202 });
    }

    if (path === "/session") {
      const data = token ?? (await tokenPromise);
      token = data;
      return Response.json({ token: token.token });
    }

    return new Response("not found", { status: 404 });
  },
};

Hit /warm once and then /session. The warming request answers immediately, as designed. Every later request that needs the token blocks forever:

curl -s -w "warm=%{http_code}\n" http://127.0.0.1:8790/warm
# warm=202

curl -s -m 45 -o /dev/null -w "session=%{http_code} time=%{time_total}\n" http://127.0.0.1:8790/session
# session=000 time=45.002827

000 is curl's code for "no HTTP response at all before my own timeout" — 45 s here, and 15 s and 8 s in two earlier runs of the same sequence. There is no 500 to alert on, no exception to log; workerd at default verbosity records nothing, and with --verbose the only clue is the absence of activity. This is why the bug reaches production as "the session endpoint is slow sometimes" and why the dashboard metric you want is not a latency percentile but a count of requests that never produced an outcome.

Cloudflare documents the adjacent case under Cannot perform I/O on behalf of a different request, with the same advice this article gives: store the data, not the I/O object. Their canonical example caches a Response in global scope and then returns it from a later invocation; the version above caches the promise leading to a Response, and the failure mode is quieter because nothing is handed back to the client until the promise settles. The documentation also notes that promises which are neither awaited, returned, nor passed to ctx.waitUntil() may be cancelled when the invocation that created them completes — which is precisely what the runtime did to the warming request's fetch in this lab. Combine a floating promise with a module-scope promise cache and you have manufactured the hang deliberately.

The fix: share values, never promises

Two rules cover all of the above. First, a cache holds resolved values; the await always happens inside the request that needs the data. Second, state that outlives a request is data a caller can inspect — never a promise a caller can await. The rewritten Worker below implements both, and its measured behaviour is the point.

// worker.js — FIXED: every await stays inside the request that started it.
// Rule 1: the cache holds the resolved value, never the in-flight promise.
// Rule 2: cross-invocation state is data a caller polls, never a promise.
let cache = { value: null, expires: 0 };
let readyFlag = false;

async function getToken(env) {
  const now = Date.now();
  if (cache.value && cache.expires > now) return cache.value;

  const res = await env.ORIGIN.fetch("https://origin.internal/token");
  if (!res.ok) throw new Error(`token endpoint returned ${res.status}`);
  const data = await res.json();

  cache = { value: data, expires: now + 60_000 };
  return cache.value;
}

export default {
  async fetch(request, env) {
    const path = new URL(request.url).pathname;

    if (path === "/session") {
      try {
        const data = await getToken(env);
        return Response.json({ token: data.token, issued: data.issued });
      } catch (err) {
        return Response.json({ error: String(err) }, { status: 502 });
      }
    }

    if (path === "/status") {
      return Response.json({ ready: readyFlag }, { status: readyFlag ? 200 : 202 });
    }

    if (path === "/signal") {
      readyFlag = true;
      return new Response("signalled\n");
    }

    return new Response("not found", { status: 404 });
  },
};

Restart workerd with this file in place and run the same endpoints. The cache works — the origin's counter proves it — and the status endpoint answers at any point in its lifecycle instead of blocking a caller on another invocation's work.

curl -s -w " |%{http_code} t=%{time_total}\n" http://127.0.0.1:8790/session
# {"token":"tok_1","issued":1} |200 t=0.303162

curl -s -w " |%{http_code} t=%{time_total}\n" http://127.0.0.1:8790/session
# {"token":"tok_1","issued":1} |200 t=0.000501

curl -s -w " |%{http_code}\n" http://127.0.0.1:8790/status
# {"ready":false} |202
curl -s -o /dev/null -w "signal=%{http_code}\n" http://127.0.0.1:8790/signal
# signal=200
curl -s -w " |%{http_code}\n" http://127.0.0.1:8790/status
# {"ready":true} |200

Read the two /session lines together. Both return tok_1 with "issued":1 — the origin was hit once, the second request was served from the cache 0.5 ms after the first returned at 303 ms. Nothing about the caching behaviour was traded away, and the workerd log for the entire fixed run is empty: no warning, no cancellation, nothing to triage.

When the cache and the flag are genuinely not enough, the documentation points at two supported homes for cross-invocation state: Durable Objects when you need coordination, Workers KV when you need a shared read cache. Both are worth the extra wiring compared to a module-scope variable, because neither can be handed a dead request's promise by accident. One caveat worth stating plainly: a per-isolate module-scope value cache like the one above is still not a distributed cache — isolates are created and evicted without notice, so it is a latency optimisation, not a source of truth.

Why the suggested compatibility flag is not a fix

The warning ends by suggesting no_handle_cross_request_promise_resolution, so I added that single line to the config and re-ran the barrier scenario unchanged. The warning disappears. The failure does not: the awaiting request was still refused with a 500 in 1.204 ms, and the resolving request — which had been clean in the unflagged run — now produced its own error.

# add one line to the main service in config.capnp, then re-run the same two curls
      compatibilityFlags = [ "no_handle_cross_request_promise_resolution" ],

# await_ready=500 t=0.001204      (identical failure, warning suppressed)
# signal=200                      (the resolver now fails internally instead)

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: $_0)

That is the trade in one run: you swap a warning that carries a file and line number for a later, vaguer failure from inside a promise, while the original request dies exactly as before. Treat the flag as what it is — an escape hatch for legacy code you cannot restructure this week, adopted with the knowledge that you are turning off the diagnostic that would have found the next instance.

What to grep for in your Worker

Four patterns cover the vast majority of real-world hits. Run them over your Worker sources, not just your entry file — the bug is usually one module deep, in a client wrapper or an auth helper.

  • A promise assigned at module scope. Search for ^let .*Promise, ^const .*Promise and anything that assigns inside a handler but is declared outside it. A promise-valued module variable is the single highest-signal indicator of this bug class.
  • Promise-valued caches and single-flight maps. ??= and ||= applied to a variable holding a fetch(), plus patterns like inflight.get(key) or inFlight.set(key, fn()). The fix is the same in every case: store the resolved data with an expiry, and let each request run its own await.
  • Barriers and locks. Anything named ready, warmup, init, lock, mutex or gate that resolves a promise from a request handler. Replace it with a state flag a caller can read, or move the coordination into a Durable Object.
  • Clients created at module scope. Database pools, SDK clients and connection objects initialised outside the handler are the same mistake one level down: the client may hold I/O from the first request that touched it. Create them per request, or let the binding's own API manage the lifecycle.

Two habits that keep the pattern from coming back. Turn on the no-floating-promises ESLint rule, which Cloudflare recommends for exactly this family of bugs — a promise the runtime may cancel is a promise you should have awaited, returned or passed to ctx.waitUntil(). And when a request must do work after answering, hand the promise to ctx.waitUntil() so the runtime keeps the context alive on purpose; fire-and-forget is what turns a cache into a landmine. Finally, treat the two log signatures as alarms worth alerting on: the code had hung cancellation is a hard failure you should page on, and the cross-request promise warning is the early symptom that precedes it.

All outputs quoted in this article were produced by execution on 2026-09-15 on a 4-vCPU arm64 host: Node 26.5.1, workerd 2026-09-15, npm's default workerd install, one Cap'n Proto config and the worker variants listed above. As a final check the config, the broken worker and the fixed worker were re-typed from this article's own code blocks into an empty directory and re-run: the failure reproduced at 1.647 ms, the fixed worker returned the same payloads at 302 ms cold and 0.56 ms warm, with an empty runtime log. The cancellation timings are therefore four separate reproductions (1.204, 1.215, 1.258, 1.647 ms) and the sub-millisecond figures move by a few tenths between runs on this host; payloads, status codes and log text were identical every time. The hang is the 45 s curl timeout of a request that never received a byte, replicated at 15 s and 8 s budgets in earlier runs. Cloudflare's documented behaviour — the "script will never generate a response" causes, the compatibility-flag escape hatch and the Durable Objects / Workers KV recommendations — is cited from the Workers errors and compatibility-flags references, last updated June and August 2026, not re-executed here.

C2CZ

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