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

Cloudflare Error 1101 Worker Threw Exception: Fix

Your Cloudflare Worker route just started serving Error 1101: Worker threw exception, and the error page gives you a Ray ID but no stack trace. Error 1101 is Cloudflare's code for one thing: your Worker hit a runtime JavaScript exception it never handled, the request died, and the edge served the generic error page instead of your response. The exception itself is never shown to the visitor — it is sitting in Workers Logs, and the fastest path to a fix is knowing which of the four classic causes you are looking at.

I hit 1101 twice this month on the same API: once from a request.json() call on an empty body, once after "refactoring" ctx.waitUntil into a destructured variable. Both took longer than they should have because the error page hides the actual exception. This post is the diagnostic path I now run first: what 1101 means, the exact causes that produce it, and the production-grade handler pattern that keeps it from taking the whole route down.

TL;DR

  • Error 1101 = unhandled JavaScript exception in your Worker. The runtime caught an error, killed the request, and rendered the error page. The real exception is in Workers Logs, not on the page.
  • Four causes cover almost every real 1101: an uncaught exception in the fetch handler; "The script will never generate a response" (an unresolved promise or a WebSocket that is never closed); "Illegal invocation" (a lost this from destructuring ctx); and caching I/O objects in global scope.
  • Debug in order: Workers & Pages → your Worker → Logs, filter $metadata.error EXISTS, or npx wrangler tail for live exceptions. The stack trace names the cause in one line.
  • Harden the handler: wrap your logic in try/catch and return a structured error response with the Ray ID; for proxy Workers, ctx.passThroughOnException() sends unhandled errors to the origin instead of the 1101 page.
  • Not every 11xx is your code. 1102 is CPU time, 1027 is the free-tier daily request limit, and other 11xx errors can mean a runtime incident — check the Cloudflare status page before rewriting code.

What Error 1101 actually means

Per the Workers runtime documentation, when a Worker in production hits an error that prevents it from returning a response, the client receives an error page carrying an error code. Code 1101 is defined as "Worker threw a JavaScript exception." The page is intentionally vague — Cloudflare does not leak your stack trace to the public — so the Ray ID on it is your only clue until you open the logs.

CodeMeaning (official)
1101Worker threw a JavaScript exception
1102Worker exceeded CPU time limit
1019Worker hit loop limit (16 Worker-to-Worker invocations)
1021Worker requested a host it cannot access
1022Cloudflare failed to route the request to the Worker
1024Worker cannot make a subrequest to a Cloudflare-owned IP
1027Worker exceeded free-tier daily request limit
1042Worker fetched another Worker on the same zone without the global_fetch_strictly_public flag

Note the difference between 1101 and the related edge failures we covered earlier: Cloudflare 524 means the origin did not answer in time — the Worker may have been fine. 1101 means your Worker code ran and threw. Also, the same exception can surface as an HTTP 500 upstream when Cloudflare's own tooling (or a client) observes the failure, which is why "Error 500 caused by Workers exceptions" shows up as a related error in the support docs.

Cause 1: an uncaught exception in the fetch handler

The boring, most common case. Something inside fetch() throws — a JSON.parse on a malformed body, a method call on undefined, a KV or D1 binding that returned null and you called .json() on it anyway. The exception propagates out of the handler, no Response is ever returned, and the runtime serves 1101. This is exactly what my empty-body request.json() incident looked like:

// POST /api/order — throws SyntaxError on an empty or non-JSON body,
// and the request dies with Error 1101 instead of a clean 4xx.
export default {
  async fetch(request) {
    const order = await request.json();
    const total = order.items.reduce((sum, item) => sum + item.price, 0);
    return new Response(JSON.stringify({ total }), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

The fix is not "catch and ignore" — it is to make the handler fail deliberately. Wrap the logic in try/catch, return a structured error response carrying the Ray ID (it is in the incoming CF-Ray header), and add the security headers the edge would otherwise add for you. A 400 with a body beats a 1101 page for every client that calls you:

export default {
  async fetch(request) {
    try {
      const order = await request.json();
      const total = order.items.reduce((sum, item) => sum + item.price, 0);
      return new Response(JSON.stringify({ total }), {
        status: 200,
        headers: jsonHeaders(),
      });
    } catch (err) {
      const rayId = request.headers.get("CF-Ray") ?? "unknown";
      console.error("order handler failed", { rayId, error: err.message });
      return new Response(
        JSON.stringify({ error: "invalid_request", rayId, message: err.message }),
        { status: 400, headers: jsonHeaders() },
      );
    }
  },
};

function jsonHeaders() {
  return {
    "Content-Type": "application/json",
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
    "Cache-Control": "no-store",
  };
}

Validation of the payload shape belongs in the same block: a JSON body that parses as null or an array will pass request.json() and then throw a TypeError on order.items — the catch converts that into a 400 as well. If you are proxying rather than building an API, skip to the passThroughOnException() pattern at the end of this post; for origin-facing APIs, the timeout chain in our nginx 499 write-up is the other half of keeping requests alive.

Cause 2: "The script will never generate a response"

Some 1101 pages carry the message "The script will never generate a response." The runtime throws this when all of your code has executed, no events are left in the event loop, and no Response was returned. In a browser the equivalent code would hang forever; the Workers runtime fails fast so you can debug it. The two triggers are an unresolved promise and a WebSocket that never closes.

First, a response that depends on a promise nobody resolves or rejects. The official example uses Promise.withResolvers() — the response is built, but the returned promise never settles:

export default {
  fetch() {
    const { promise, resolve } = Promise.withResolvers();
    // resolve("ok"); // without this, the runtime throws 1101:
    //                // "The script will never generate a response"
    return promise.then(() => new Response("Example response"));
  },
};

Look for promises in your code or in dependencies that gate the Response: an await on a promise that only resolves inside an event that never fires, a queue drained by a callback that never runs. The no-floating-promises ESLint rule catches promises that are created and never handled — worth enabling if this bites you.

Second, WebSockets. A server-side WebSocket that is accepted but never closed keeps an event alive forever. The 'close' event from the client must actually close the server side:

async function handleRequest(request) {
  const [client, server] = Object.values(new WebSocketPair());
  server.accept();

  server.addEventListener("close", () => {
    // Without server.close(), the server-side connection stays open
    // and requests end in "The script will never generate a response".
    server.close();
  });

  return new Response(null, { status: 101, webSocket: client });
}

Cause 3: Illegal invocation — the ctx trap

The error message TypeError: Illegal invocation: function called with incorrect this reference looks like a framework bug, but it is standard JavaScript: you called a method that relies on this, and this was lost. The classic Workers version is destructuring ctx. ctx.waitUntil needs its this to be ctx — pull it into a local variable and it breaks:

export default {
  async fetch(request, env, ctx) {
    const { waitUntil } = ctx;      // waitUntil loses its `this` reference
    waitUntil(logRequest(request)); // TypeError: Illegal invocation
    return fetch(request);
  },
};

Call the method on ctx directly, or re-bind it with call, apply, or bind:

export default {
  async fetch(request, env, ctx) {
    ctx.waitUntil(logRequest(request)); // fine: this === ctx

    const { waitUntil } = ctx;
    waitUntil.call(ctx, logRequest(request)); // fine: re-bound to ctx
    return fetch(request);
  },
};

Cause 4: I/O objects cached in global scope

This one throws a different runtime error that produces 1101: "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." Each Worker invocation has its own execution context; caching a Response or Request in module scope and reusing it on the next request breaks that isolation.

let cachedResponse = null; // I/O objects are per-request — do not cache them

export default {
  async fetch(request) {
    if (cachedResponse) return cachedResponse;
    cachedResponse = new Response("Hello, world!");
    await new Promise((resolve) => setTimeout(resolve, 5000));
    return cachedResponse; // second request: Cannot perform I/O on behalf of a different request
  },
};

Cache data, not I/O objects. Store the body text and build a fresh Response per request; if you need durable state across requests, use KV or Durable Objects, not globals:

let cachedData = null; // store data, not I/O objects

export default {
  async fetch() {
    if (cachedData) return new Response(cachedData);
    const response = new Response("Hello, world!");
    cachedData = await response.text();
    return new Response(cachedData, response);
  },
};

Find the real exception: logs and wrangler tail

Stop guessing from the error page. The dashboard route is Workers & Pages → your Worker → Logs. In the log query box, filter with $metadata.error EXISTS to see every invocation that ended in an error, or $workers.outcome = "exception" to narrow to uncaught exceptions — the log entry carries the exception name, message, and stack trace that the 1101 page hides.

For live traffic, tail the Worker directly. The official debugging path is wrangler tail, which streams events and puts uncaught exceptions under the exceptions field of each event:

npx wrangler tail your-worker-name --format json

Trigger the failing request once and you will see the exception with its name, message, and the Worker line that threw — for my empty-body case it was a SyntaxError: Unexpected end of JSON input pointing at the request.json() line. Redeploy after fixing, keep tailing, and confirm the next invocation has no exceptions field. The same discipline of reading the exact error before patching applies to serverless HTTP callers — see our earlier debugging walkthrough on the Turso SERVER_ERROR 404 case.

Production-grade error handling for proxy Workers

If your Worker is a passthrough or an edge layer in front of an origin, you do not want a thrown exception to burn the whole request. Call ctx.passThroughOnException() at the top of the handler: unhandled exceptions in your Worker code are then forwarded to the origin, as if the Worker was not there. This is the difference between a blip and a visible outage for visitors.

export default {
  async fetch(request, env, ctx) {
    ctx.passThroughOnException(); // unhandled Worker errors go to the origin
    return fetch(request);        // instead of a 1101 error page
  },
};

Two caveats from the docs: passThroughOnException() covers exceptions in your Worker code, not errors from the origin fetch() — wrap origin fetches in try/catch and return a 5xx yourself. And if the origin fetch has already consumed the request body when it throws, the pass-through cannot replay the body. For API Workers that should never fall through, keep the try/catch pattern from Cause 1 instead.

Verify the fix

  1. Redeploy the Worker (npx wrangler deploy) and hit the failing route from a clean browser or curl -i.
  2. Expect your response, not the 1101 page: for the API example, a 200 with your JSON on valid input and a 400 with error/rayId fields on bad input.
  3. Confirm zero exceptions: run npx wrangler tail, repeat the request, and verify no exceptions field appears.
  4. Check the metrics chart: Workers & Pages → your Worker → Overview → Errors by invocation status should show no "Uncaught Exception" in the window you just tested.
  5. Note the Ray ID from any error page you still see — if the exception survives your fix, Cloudflare Support will ask for the Ray ID, the Worker name, recent changes, and reproduction steps.

When 1101 is not your code

Before you rewrite a working handler, rule out the codes that are not exceptions. 1102 means your Worker exceeded the CPU time limit — optimize loops, avoid buffering large responses, and consider the Paid plan's extended CPU budget. 1027 means you blew the free-tier daily request limit; check your usage dashboard, not your code. Other 11xx errors can indicate a Workers runtime incident — when the failure is on Cloudflare's side, the Cloudflare status page will say so, and requests typically recover without a deploy.

Error 1101 is almost always a ten-minute log read away from a fix. Find the exception, apply the matching pattern above, redeploy, and verify with tail — then the next 1101 you see will be a diagnosis, not a mystery. If the failure is on the origin side of the edge instead, our Cloudflare 524 deep-dive covers the timeout path where the Worker never even runs.

C2CZ

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