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

Cloudflare Error 1042: Worker Fetch to Same Zone Fails

Your Worker's fetch() call to a hostname on its own Cloudflare zone dies with Error 1042 — Worker tried to fetch from another Worker on the same zone, while the identical code passes in wrangler dev. This is not a runtime bug and not a DNS problem: it is a routing decision. A subrequest from a Worker back into its own zone is ambiguous — the runtime cannot tell whether it should be trusted and sent straight to the origin, or untrusted and pushed through Cloudflare's front door with every Worker, rule and WAF check re-applied — so it refuses the ambiguous case outright unless you opt in.

I shipped exactly this on a split Worker setup: an app Worker on app.example.com calling an auth Worker on auth.example.com, same zone, through its public hostname. Preview was green, local was green, and production served 1042 error pages to every request that needed a token. The routing rule that broke it takes one config change to satisfy — but only if you know which of the three fixes applies to your case.

TL;DR

  • Error 1042 means the runtime refused a same-zone Worker-to-Worker subrequest. Cloudflare's Workers errors documentation defines it as "Worker tried to fetch from another Worker on the same zone, which is only supported when the global_fetch_strictly_public compatibility flag is used". It is one of the error pages a Worker generates when it cannot return a response; like the other 1xxx codes it appears in the HTML body of the response, not in the HTTP status.
  • Why the rule exists: when a Worker subrequests its own zone, Cloudflare has no way to know whether that call is internal (trusted, should go to the origin) or external (untrusted, should re-enter the front door). The default is the origin — Workers mapped to that URL are skipped. The runtime now fails fast with 1042 instead of silently bypassing your own code.
  • Fix order: a Service binding (RPC or HTTP) is the documented path and the one Cloudflare recommends; global_fetch_strictly_public is the escape hatch when you specifically want the public path; a dedicated non-apex hostname is the workaround for zone-internal endpoints such as /cdn-cgi/media/.
  • Local development lies to you. wrangler dev runs a local runtime and does not apply same-zone routing, which is why this class of bug only appears after wrangler deploy. There is an open-then-closed issue about exactly that (workers-sdk #11215).
  • It is not the same failure as Error 1101 (your code threw), Error 1102 (resource limit), Error 1016 (edge DNS cannot resolve the target) or Error 1020 (a policy blocked the request). 1042 happens before any of them: the subrequest never reaches a handler.

What Error 1042 actually means

Read the definition again, because the wording carries the whole diagnosis: tried to fetch from another Worker on the same zone. Two conditions must both be true for the error to appear — the target hostname must be on the same zone as the Worker making the call, and Cloudflare must be routing that hostname to a Worker.

Under the hood, a Worker subrequest to its own zone takes a different path from a normal outbound request. Cloudflare's community team explained the historical behaviour back when Worker composition first came up: "such request chaining isn't possible on same-zone subrequests. Instead, we always send those to the origin." The reason given is ambiguity — a subrequest offers no signal about whether it is trusted, and re-entering the front door would re-run every Cloudflare feature on it, including the same Worker again.

Cross-zone subrequests never had this problem. A call from app.example.com to api.other-domain.com is unambiguously an external request, so it goes out and comes back through the front door normally. If your two Workers sit on different zones, you will not see 1042 — you will see the target Worker run, with all the latency and feature processing that implies.

The three paths a same-zone subrequest can take

  • Route to the zone's origin (the legacy default). Workers mapped to that URL are bypassed, and so are Cloudflare security settings. This is what global_fetch_strictly_public disables.
  • Route through the front door — the opt-in behaviour of global_fetch_strictly_public, where the request is "treated like a request from the Internet, possibly even looping back to the same Worker again".
  • Bypass the public Internet entirely — a Service binding, which delivers the request straight into the target Worker without a publicly-accessible URL, DNS lookup or edge hop.

That third option is why Cloudflare's guidance on the errors page points at the compatibility flag only as an alternative. Service bindings exist precisely for Worker-to-Worker traffic, and they are the fix I would pick nine times out of ten.

Why the same code passes in wrangler dev

wrangler dev runs your Worker in a local runtime (Miniflare) on your machine. There is no zone, no front door, and no origin routing table, so a fetch() to auth.example.com either resolves to a local session you started or goes out to the real Internet. Nothing in that environment can reproduce Cloudflare's same-zone routing rule.

The reporter of workers-sdk issue #11215 put it plainly: "I was making a request to the worker itself inside the worker and it gives a 1042. I can't find documentation that it is not allowed. It is allowed in wrangler which makes it harder to reproduce, or discover while developing." A maintainer's reply on the same issue states the fix: "To make fetches between workers in the same zone, you need to either use service bindings or set the global_fetch_strictly_public flag if you want fetch to always go via the public internet."

That asymmetry is the trap. Your tests, your preview deploy, and your local multi-Worker setup all pass; the failure appears the first time real traffic hits a real zone. Treat any Worker-to-Worker call in your codebase as unverified until you have run it against a deployed zone.

Diagnose: find the fetch that crosses your own zone

Before changing configuration, find the offending subrequest. Start from production logs, because the local logs cannot show you this failure mode.

# 1. Tail only the failed invocations of the caller Worker.
npx wrangler tail app-worker --format pretty --status error

# 2. List every outbound fetch in the repo, then check each host against your zone.
grep -rn --include='*.ts' --include='*.js' --include='*.tsx' \
  -E "fetch\((['\"\`])https?://" src/ functions/ | head -40

Two patterns account for almost every 1042 in the wild. The first is a Worker calling a sibling Worker's public hostname instead of a binding. The second is a Worker calling something that looks like infrastructure but is itself served by a Worker on the same zone — a media transformation endpoint, an image resizer, an internal API fronted by a Wildcard route. If your route pattern is *.example.com/*, assume the second pattern applies until you prove otherwise.

You can also confirm it from the outside. The 1042 marker is in the response body, so fetch the URL and grep it:

# Confirm the public symptom: a 1xxx error page, not your JSON.
curl -sS -o /tmp/me.body -w "%{http_code}\n" \
  -H "authorization: Bearer $TOKEN" \
  https://app.example.com/api/me
grep -c "Error 1042" /tmp/me.body || true

A non-zero count with a JSON content-type that you did not set is the signature: the client is being handed Cloudflare's error page, not your handler's output. With the failing hostname identified, pick the fix below.

Fix 1 — Service binding with RPC (the recommended path)

A Service binding lets one Worker call into another without going through a publicly-accessible URL: no DNS, no front door, no 1042, and per Cloudflare's docs, no added latency — by default both Workers run on the same thread of the same server. The caller declares the binding, and the target exposes methods.

The caller's Wrangler configuration names the bound service and, for a named entrypoint class, the entrypoint key. Note that auth-worker has no route of its own: nothing but a binding can reach it.

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "app-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-10",
  "routes": [{ "pattern": "app.example.com", "custom_domain": true }],
  "services": [
    { "binding": "AUTH", "service": "auth-worker", "entrypoint": "AuthService" }
  ]
}

The target Worker extends WorkerEntrypoint from cloudflare:workers and exposes plain async methods. Bindings are available on this.env, so the JWT secret stays a Worker secret (wrangler secret put JWT_SECRET) and never appears in a config file or in source.

// auth-worker/src/auth.ts — the RPC entrypoint. No public route points here.
import { WorkerEntrypoint } from "cloudflare:workers";

interface Env {
  JWT_SECRET: string;
}

interface Claims {
  sub: string;
  exp: number;
}

export class AuthService extends WorkerEntrypoint {
  async verifyToken(token: string): Promise {
    const [header, payload, signature] = token.split(".");
    if (!header || !payload || !signature) throw new Error("malformed_token");

    const key = await crypto.subtle.importKey(
      "raw",
      new TextEncoder().encode(this.env.JWT_SECRET),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["verify"],
    );

    const valid = await crypto.subtle.verify(
      "HMAC",
      key,
      base64UrlToBytes(signature),
      new TextEncoder().encode(`${header}.${payload}`),
    );
    if (!valid) throw new Error("invalid_signature");

    const claims = JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))) as Claims;
    if (typeof claims.exp !== "number" || claims.exp * 1000 <= Date.now()) {
      throw new Error("token_expired");
    }
    return { sub: String(claims.sub), exp: claims.exp };
  }
}

// Keep a fetch handler so the Worker still deploys as a module Worker.
export default {
  fetch(): Response {
    return new Response("Not Found", { status: 404 });
  },
};

function base64UrlToBytes(value: string): Uint8Array {
  const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

On the caller side, the binding appears on env as an object you can call methods on directly. In Hono, type the binding and call it like any other async function — a rejected verification becomes a clean 503 instead of Cloudflare's error page.

// app-worker/src/index.ts — Hono caller using the RPC binding.
import { Hono } from "hono";

interface Claims {
  sub: string;
  exp: number;
}

interface AuthService {
  verifyToken(token: string): Promise;
}

type Bindings = {
  AUTH: AuthService;
};

const app = new Hono<{ Bindings: Bindings }>();

app.get("/api/me", async (c) => {
  const header = c.req.header("authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : "";

  if (!token) {
    return c.json({ error: "missing_bearer_token" }, 401);
  }

  try {
    const claims = await c.env.AUTH.verifyToken(token);
    c.header("Cache-Control", "no-store");
    c.header("X-Content-Type-Options", "nosniff");
    return c.json({ sub: claims.sub, exp: claims.exp }, 200);
  } catch (error) {
    console.error("auth_verify_failed", error);
    return c.json({ error: "auth_unavailable" }, 503);
  }
});

export default app;

Two lifecycle details from the Service bindings documentation matter in production. Each request to a Worker over a binding counts toward your subrequest limit, and a single request has a maximum of 32 Worker invocations — a fan-out loop that calls the same service more than a few times will hit that ceiling. Deploy order matters too: the target Worker must exist before the caller that binds to it, or the caller's deploy fails on the unresolved binding.

💡 One more design benefit worth the migration on its own: a Worker with no route is not reachable from the public Internet. Moving internal endpoints behind bindings takes them off the attack surface instead of relying on a WAF rule to keep them private.

Fix 2 — Service binding over HTTP (when you already have a fetch handler)

If the target Worker already exposes an HTTP API — or you are calling a Hono app you would rather not convert into an RPC entrypoint — the binding also supports request forwarding. Same config shape, no entrypoint key, and the caller just calls fetch() on the binding object.

// app-worker/src/index.ts — forward the incoming request across the binding.
import { Hono } from "hono";

type Bindings = {
  INTERNAL: { fetch(request: Request): Promise };
};

const app = new Hono<{ Bindings: Bindings }>();

// Method, headers and body are all carried over; the binding routes to the Worker,
// not to a hostname, so there is nothing for the same-zone rule to refuse.
app.all("/internal/*", (c) => c.env.INTERNAL.fetch(c.req.raw));

export default app;

The target still needs its own Wrangler config with name and main, and it does not need a route. Because the request is delivered as a normal Request object, this path is the drop-in replacement for the pattern that broke: anywhere your code did fetch("https://auth.example.com/verify"), it now calls env.AUTH.fetch(...) on a constructed Request and the same-zone rule never applies.

Fix 3 — global_fetch_strictly_public (when you want the public path)

Sometimes the public path is the point: you want cache rules, WAF, Access, or the zone's own routing logic to apply to the subrequest. That is what the compatibility flag enables. Per the compatibility flags reference, with global_fetch_strictly_public enabled the global fetch() "will strictly route requests as if they were made on the public Internet", meaning requests to the Worker's own zone loop back to the front door.

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "app-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-10",
  "compatibility_flags": ["global_fetch_strictly_public"]
}

Without the flag, the same subrequest is sent to the zone's origin while "ignoring any Workers mapped to the URL and also bypassing Cloudflare security settings" — that asymmetry is the reason the runtime throws 1042 rather than guessing. The reference lists no default-on date for the flag, with global_fetch_private_origin as its matching opt-out, so treat it as an explicit per-Worker decision.

Two consequences to design for before you flip it on. First, the subrequest is now a real Internet request: every Cloudflare feature applies to it, including the ones that can block it, and it can loop back into the same Worker that made the call. Second, Cloudflare's cross-Worker loop guard is documented on the errors page — a Worker cannot call itself or another Worker more than 16 times, tracked by a countdown in the CF-EW-Via header, after which the client gets 1019. If your route pattern catches the hostname you fetch, mark internal hops explicitly and handle them without re-fetching.

// Guarded self-subrequest: never let an internal hop re-enter the fetch path.
export default {
  async fetch(request) {
    const url = new URL(request.url);
    const isInternalHop = request.headers.get("x-internal-hop") === "1";

    if (url.pathname === "/internal/health" && isInternalHop) {
      const body = JSON.stringify({
        ok: true,
        invocationsLeft: request.headers.get("cf-ew-via") ?? "unknown",
      });
      return new Response(body, {
        status: 200,
        headers: { "content-type": "application/json" },
      });
    }

    const response = await fetch("https://app.example.com/internal/health", {
      headers: { "x-internal-hop": "1" },
    });

    return new Response(response.body, {
      status: response.status,
      headers: {
        "content-type": response.headers.get("content-type") ?? "application/json",
        "X-Content-Type-Options": "nosniff",
      },
    });
  },
};

This pattern is worth keeping even after you migrate to bindings: it documents the intent of the internal route in code, and it gives you a single place to stop a recursion bug before it burns 16 invocations and a 1019 error.

Fix 4 — the zone-internal endpoint that no flag will route

One class of same-zone fetch is not a Worker of yours at all: Cloudflare's own zone-hosted endpoints under /cdn-cgi/, such as media transformations. A Community thread on 1042 with /cdn-cgi/media/ shows the flag alone does not make those subrequests work when the target is the same hostname that triggered the Worker. The workaround discussed there is to call a different hostname: create a proxied record such as media.example.com and issue the subrequest from the Worker on app.example.com against that hostname instead.

That is the general rule for anything zone-hosted that you cannot reach over a binding: if the target must be reached by URL and it lives on your zone, put it behind its own hostname rather than calling the apex or the same hostname you are serving. The subrequest then crosses zones from the runtime's point of view and the same-zone restriction does not apply.

Harden the caller while you are in there

A Service binding removes 1042, but it does not remove transient failures — an overloaded target, a timeout, a 503 during a deploy. Wrap cross-service calls in one helper with an explicit timeout, bounded retries and a JSON guard, so a bad upstream surfaces as a clean 503 rather than as an unhandled exception that turns into a 1101.

// Shared subrequest helper: timeout, bounded retry, content-type guard.
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);

interface FetchJsonOptions {
  timeoutMs?: number;
  attempts?: number;
}

export async function fetchJson<T>(
  url: string,
  init: RequestInit = {},
  options: FetchJsonOptions = {},
): Promise<T> {
  const { timeoutMs = 5_000, attempts = 3 } = options;
  let lastError = "unknown";

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const response = await fetch(url, { ...init, signal: controller.signal });

      if (response.ok) {
        const contentType = response.headers.get("content-type") ?? "";
        if (!contentType.includes("application/json")) {
          throw new Error(`unexpected_content_type:${contentType || "none"}`);
        }
        return (await response.json()) as T;
      }

      lastError = `upstream_${response.status}`;
      if (!RETRYABLE_STATUS.has(response.status)) break;
    } catch (error) {
      lastError = error instanceof Error ? error.message : String(error);
    } finally {
      clearTimeout(timer);
    }

    if (attempt < attempts) {
      await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** (attempt - 1)));
    }
  }

  throw new Error(`subrequest_failed:${lastError}`);
}

Retry only idempotent calls — a GET that failed before a response was written. For a POST that creates something, either make the operation idempotent with a request key or drop the retry: a duplicated write is worse than a 503 the client can decide about. And keep the timeout shorter than your own request budget, so the retry loop cannot outlive the request that started it.

Verify the fix

Verification has three parts: local wiring, deploy order, and the public symptom disappearing. Local is where you confirm the binding exists and is reachable; run both Workers at once with multiple -c flags and read the binding state in the Wrangler banner.

# 1. Local: the primary Worker plus the bound Worker, one command.
npx wrangler dev -c wrangler.jsonc -c ../auth-worker/wrangler.jsonc
#    Expect the banner to show:  AUTH: auth-worker [connected]

# 2. Deploy the target first — the caller binds to it by name.
(cd ../auth-worker && npx wrangler deploy)
npx wrangler deploy

# 3. Confirm the endpoint answers with your JSON, not an error page.
curl -sS -o /tmp/me.body -w "%{http_code}\n" \
  -H "authorization: Bearer $TOKEN" \
  https://app.example.com/api/me
head -c 200 /tmp/me.body
grep -c "Error 1042" /tmp/me.body || true

Expected result: 200, a JSON body with your claims, and a grep count of 0. Two extra checks catch the failures that look like 1042 but are not. If the body is a JSON 503 from your own handler, the binding is reachable but the target threw — read its logs with wrangler tail auth-worker. If the response is still a Cloudflare error page with a different code, you are back in the 1xxx family: 1016 for edge DNS, 1102 for a CPU limit.

Which fix to use

SituationUseWhy
Internal API between two of your WorkersService binding, RPCNo public URL, no added latency, typed method calls, target off the Internet
Target already exposes an HTTP fetch handlerService binding, HTTPForward the Request unchanged; no refactor of the target
You need Cloudflare features (WAF, Access, cache, rules) applied to the subrequestglobal_fetch_strictly_publicRoutes through the front door as a public request; design for the loop and the 16-invocation guard
Zone-hosted endpoint that no binding can reach, for example /cdn-cgi/media/Dedicated proxied hostnameCalling another hostname makes the subrequest cross-zone and sidesteps the same-zone rule

Related Cloudflare error posts

Error 1042 belongs to the family of edge errors that start before your application code runs. These are the neighbours we have already taken apart on this blog:

Rule of thumb: if the error page names a fetch target or a route, it is infrastructure configuration — bindings, DNS, routing, limits. If it names your exception or your resource usage, it is code. Error 1042 is firmly in the first group, and the fix is a line in a Wrangler config, not a rewrite.

C2CZ

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