Skip to content
Gary Wu
Go back

Cloudflare Service Binding Pitfalls

Edit page

Your service bindings work perfectly — until your target Worker exports a Durable Object class. Then .fetch() throws a cryptic error with zero documentation about why.

You are building a multi-Worker system on Cloudflare. Workers need to call each other. You use service bindings because the docs say they are zero-cost, zero-latency, and run on the same thread. This is true — until it is not. Two undocumented behaviors will break your system in ways that are extremely difficult to diagnose.

This article documents both issues, provides a complete diagnosis framework, and gives you the architecture patterns that avoid them entirely.

What You Will Learn

Table of Contents

Open Table of Contents

The Problem

You have a Cloudflare Workers architecture where multiple Workers collaborate. Worker A handles pipeline orchestration. Worker B is an API gateway with Durable Objects for state management. Worker A needs to call Worker B.

You configure a service binding:

// Worker A (Scram Jet) — wrangler.jsonc
{
  "services": [
    { "binding": "APIMOM", "service": "api-mom" }
  ]
}

Worker B exports a default fetch handler and two Durable Object classes:

// Worker B (API Mom) — src/index.ts
export { CapabilityRegistryDO } from "./capability-registry-do";
export { RunnerDO } from "./runner-do";

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // ... routing logic
  },
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
    // ... cron logic
  },
};

Worker A calls the binding:

const response = await env.APIMOM.fetch("http://api-mom/v1/capabilities/ubermesh-crud");

Expected result: An HTTP response from Worker B’s fetch handler.

Actual result:

Error: Callback returned incorrect type; expected 'Promise'

The binding exists. The .fetch() method exists. The error message says nothing about service bindings, Durable Objects, exports, or RPC. You will spend hours debugging this.

Meanwhile, a different Worker pair works perfectly:

// Worker C (Gallery) — wrangler.jsonc
{
  "services": [{ "binding": "UBERMESH", "service": "ubermesh" }]
}
// Worker D (UberMesh) — src/index.ts
// NO Durable Object class exports at the module level
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // ... routing logic
  },
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
    // ... cron logic
  },
};
// Worker C calls the binding — works perfectly
const response = await env.UBERMESH.fetch("http://ubermesh/api/types");
console.log(response.status); // 200

The only difference? UberMesh does not export Durable Object classes from its main module. API Mom does.

This is the silent breaking change. It is not documented anywhere in the Cloudflare docs, and the error message gives you zero signal about its cause.


Three Ways Workers Talk to Each Other

Before diving into the bugs, you need to understand the three communication mechanisms Cloudflare provides. Each has different semantics, different failure modes, and different configuration requirements.

1. Public URL Fetch (HTTP over the Internet)

The simplest approach. Worker A calls Worker B’s public URL using the global fetch() function.

// Worker A calls Worker B via public URL
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const response = await fetch("https://api-mom.myaccount.workers.dev/v1/health");
    const data = await response.json();
    return Response.json({ upstream: data });
  },
};

How it works: The request leaves Worker A, goes through Cloudflare’s edge network (including WAF, caching, etc.), and arrives at Worker B as a normal HTTP request. Worker B’s fetch() handler processes it.

Configuration: None required between the Workers. Worker B just needs to be deployed.

// Worker A — wrangler.jsonc
// No service binding needed
{
  "name": "worker-a",
  "main": "src/index.ts"
}

Characteristics:

PropertyValue
LatencyExtra network hop (edge routing)
CostCounts as a subrequest (50 per invocation limit)
AuthenticationMust be implemented in Worker B
Type safetyNone (raw HTTP)
Failure modeNetwork errors, DNS resolution, Workers limit errors

Key insight: Public URL fetch seems like the simplest path, but it has a critical limitation on *.workers.dev domains that we cover in The workers.dev Routing Trap. Do not rely on this method without custom domains.

2. Service Bindings — HTTP Style (.fetch())

Service bindings create a direct channel between Workers without going through the public internet. The calling Worker gets a Fetcher object on its env that exposes a .fetch() method.

// Worker A calls Worker B via service binding
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // env.WORKER_B is a Fetcher — same interface as global fetch()
    const response = await env.WORKER_B.fetch("http://worker-b/v1/health");
    const data = await response.json();
    return Response.json({ upstream: data });
  },
};

Configuration:

// Worker A — wrangler.jsonc
{
  "name": "worker-a",
  "main": "src/index.ts",
  "services": [
    {
      "binding": "WORKER_B",
      "service": "worker-b"
    }
  ]
}
// Worker B — src/index.ts
// MUST export a default object with a fetch handler
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/v1/health") {
      return Response.json({ ok: true });
    }
    return new Response("Not Found", { status: 404 });
  },
};

Characteristics:

PropertyValue
LatencyZero — runs on the same thread
CostZero — does not count as a subrequest
AuthenticationNot needed (binding = authorization)
Type safetyNone (raw HTTP Request/Response)
Failure modeBreaks if target exports DO classes

The hostname in the URL passed to .fetch() is ignored. You can use anything: "http://worker-b/path", "http://fake/path", even "http://localhost/path". Only the path matters.

// All of these are equivalent:
await env.WORKER_B.fetch("http://worker-b/v1/health");
await env.WORKER_B.fetch("http://localhost/v1/health");
await env.WORKER_B.fetch("http://anything/v1/health");
await env.WORKER_B.fetch(new Request("http://x/v1/health", { method: "POST", body: "data" }));

Key insight: HTTP-style service bindings are the legacy approach. They work reliably only when the target Worker has a single default export with no named class exports. The moment you add a Durable Object or WorkerEntrypoint export, the binding behavior changes.

3. RPC (WorkerEntrypoint) — Method Calls

The newest mechanism. Worker B extends WorkerEntrypoint and exposes public methods. Worker A calls them directly as JavaScript function calls.

// Worker B — src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

export default class ApiGateway extends WorkerEntrypoint {
  async fetch(request: Request): Promise<Response> {
    // Still handles HTTP requests for backwards compatibility
    return new Response("Use RPC methods instead", { status: 200 });
  }

  // RPC methods — callable directly from other Workers
  async getHealth(): Promise<{ ok: boolean; providers: string[] }> {
    return { ok: true, providers: ["openai", "anthropic"] };
  }

  async getCapability(name: string): Promise<Capability | null> {
    const db = createDb(this.env.DB);
    return db.query.capabilities.findFirst({
      where: eq(schema.capabilities.name, name),
    });
  }

  async proxyLLM(provider: string, payload: unknown): Promise<unknown> {
    // ... proxy logic with cost tracking
  }
}

Calling from Worker A:

// Worker A — direct method calls, no HTTP involved
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Direct RPC call — returns JavaScript objects, not Response
    const health = await env.API_GATEWAY.getHealth();
    console.log(health.ok); // true

    const cap = await env.API_GATEWAY.getCapability("ubermesh-crud");
    if (cap) {
      console.log(cap.name, cap.provider);
    }

    return Response.json({ health, capability: cap });
  },
};

Configuration:

// Worker A — wrangler.jsonc
{
  "name": "worker-a",
  "main": "src/index.ts",
  "services": [
    {
      "binding": "API_GATEWAY",
      "service": "api-gateway"
    }
  ]
}

Characteristics:

PropertyValue
LatencyZero — runs on the same thread
CostZero — does not count as a subrequest
AuthenticationNot needed (binding = authorization)
Type safetyFull TypeScript types via wrangler types
Failure modeClean errors if method does not exist
CompatibilityRequires compatibility_date >= 2024-04-03 or "rpc" flag

Named Entrypoints allow you to export multiple WorkerEntrypoint classes from a single Worker:

// Worker B — src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

// Default entrypoint
export default class extends WorkerEntrypoint {
  async fetch(request: Request): Promise<Response> {
    return new Response("OK");
  }
}

// Named entrypoint for admin operations
export class AdminAPI extends WorkerEntrypoint {
  async createProject(name: string): Promise<{ id: string }> {
    // ...
  }
  async deleteProject(id: string): Promise<void> {
    // ...
  }
}

// Named entrypoint for public API
export class PublicAPI extends WorkerEntrypoint {
  async getStatus(): Promise<{ healthy: boolean }> {
    return { healthy: true };
  }
}
// Worker A — wrangler.jsonc
{
  "services": [
    {
      "binding": "ADMIN",
      "service": "api-gateway",
      "entrypoint": "AdminAPI"
    },
    {
      "binding": "PUBLIC_API",
      "service": "api-gateway",
      "entrypoint": "PublicAPI"
    }
  ]
}
// Worker A — usage
const project = await env.ADMIN.createProject("my-project");
const status = await env.PUBLIC_API.getStatus();

Key insight: RPC is Cloudflare’s recommended approach for all new Worker-to-Worker communication. It is type-safe, zero-cost, and does not suffer from the silent breaking change that affects HTTP-style service bindings. If you are starting a new multi-Worker system, use RPC from day one.

Summary: Three Mechanisms Compared

FeaturePublic URL FetchService Binding (.fetch())RPC (WorkerEntrypoint)
LatencyNetwork hopZeroZero
Cost per call1 subrequestFreeFree
Auth neededYesNoNo
Type safetyNoNoYes
Works across accountsYesNoNo
Works on *.workers.devNo (same account)ConditionalConditional
Handles DO exportsN/ANoYes
ConfigurationNoneService bindingService binding
Data formatHTTP Request/ResponseHTTP Request/ResponseJavaScript objects

The Silent Breaking Change: DO Exports Poison Service Bindings

This is the core finding. It is undocumented as of April 2026. Every official example of service bindings targets a Worker with only a default export. None of the docs show what happens when the target also exports Durable Object classes.

What Happens

When a Worker exports named classes at the module level — specifically Durable Object classes or WorkerEntrypoint subclasses — alongside its default export, the Wrangler/workerd runtime treats the service binding as an RPC binding rather than an HTTP/fetch binding.

The newer RPC system (introduced with WorkerEntrypoint in April 2024) interferes with the plain .fetch() method. The runtime sees the named exports, assumes you want RPC semantics, and the .fetch() call fails because the internal dispatch path is different.

The Error

Error: Callback returned incorrect type; expected 'Promise'

This error tells you nothing about:

It reads like a generic JavaScript type error, which sends you searching in completely the wrong direction.

Reproduction

Step 1: Create a target Worker with DO exports.

// target-worker/src/index.ts
import { DurableObject } from "cloudflare:workers";

// This Durable Object class is the poison
export class MyDurableObject extends DurableObject {
  async fetch(request: Request): Promise<Response> {
    return new Response("DO response");
  }
}

// Standard default export — should work as a service binding target
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    return Response.json({ ok: true, source: "default-handler" });
  },
};
// target-worker/wrangler.jsonc
{
  "name": "target-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "durable_objects": {
    "bindings": [
      { "name": "MY_DO", "class_name": "MyDurableObject" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_classes": ["MyDurableObject"] }
  ]
}

Step 2: Create a calling Worker with a service binding.

// caller-worker/src/index.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    try {
      // This will throw: "Callback returned incorrect type; expected 'Promise'"
      const response = await env.TARGET.fetch("http://target/health");
      return Response.json({ status: response.status });
    } catch (err) {
      return Response.json({
        error: err.message,
        bindingType: typeof env.TARGET,
        fetchType: typeof env.TARGET.fetch,
      }, { status: 500 });
    }
  },
};
// caller-worker/wrangler.jsonc
{
  "name": "caller-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "services": [
    { "binding": "TARGET", "service": "target-worker" }
  ]
}

Step 3: Deploy both and call the caller. You will get:

{
  "error": "Callback returned incorrect type; expected 'Promise'",
  "bindingType": "object",
  "fetchType": "function"
}

The binding exists. The .fetch() method exists. But calling it throws.

Why It Happens

The workerd runtime (Cloudflare’s Workers engine) has two dispatch paths for service bindings:

  1. HTTP dispatch — used when the target Worker has only a default export (object literal with fetch/scheduled/queue handlers). The binding provides a Fetcher with a working .fetch() method.

  2. RPC dispatch — used when the target Worker has any named class exports that the runtime recognizes as entrypoints. This includes WorkerEntrypoint subclasses and, critically, DurableObject subclasses that are exported at the module level.

When the runtime detects named class exports, it switches to the RPC dispatch path. The .fetch() method on the binding still exists (it is part of the RPC protocol), but it behaves differently. The internal plumbing expects RPC-style argument passing, not HTTP-style Request passing, and the type mismatch causes the “Callback returned incorrect type” error.

The Insidious Part

This behavior is invisible in the binding configuration. There is nothing in your wrangler.jsonc that tells you which dispatch path will be used. The dispatch path is determined entirely by what the target Worker exports. You can have two identical service binding configurations:

{
  "services": [
    { "binding": "UBERMESH", "service": "ubermesh" },
    { "binding": "APIMOM", "service": "api-mom" }
  ]
}

One works. One breaks. The only way to know which is which is to inspect the target Worker’s source code and check for named class exports.

The Fix: Explicit Entrypoint

The workaround is to add "entrypoint": "default" to your service binding configuration:

{
  "services": [
    {
      "binding": "APIMOM",
      "service": "api-mom",
      "entrypoint": "default"
    }
  ]
}

This forces the runtime to bind specifically to the default export, bypassing the RPC dispatch path. It tells workerd: “I want the default entrypoint’s .fetch() method, not the named exports.”

This is what we discovered in the Scram Jet configuration:

// scram-jet/packages/engine/wrangler.jsonc
{
  "services": [
    { "binding": "MEDIA_STORE", "service": "media-store", "entrypoint": "default" },
    { "binding": "APIMOM", "service": "api-mom", "entrypoint": "default" }
  ]
}

The "entrypoint": "default" is what makes these bindings work, even though API Mom exports CapabilityRegistryDO and RunnerDO at the module level.

Compare with a binding that does NOT need this workaround:

// ubermesh/apps/gallery/wrangler.jsonc
{
  "services": [{ "binding": "UBERMESH", "service": "ubermesh" }]
}

UberMesh has no Durable Object class exports in its main module, so the plain service binding configuration works fine.


The workers.dev Routing Trap

Your first instinct when service bindings break is to fall back to a public URL fetch. If you are using *.workers.dev domains, this will also fail.

What Happens

Workers deployed to the same Cloudflare account cannot reach each other via their *.workers.dev URLs. Calling fetch("https://other-worker.my-subdomain.workers.dev/...") from within a Worker on the same account returns error code 1042 or times out.

// Inside Worker A — this FAILS on the same account
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    try {
      const response = await fetch(
        "https://api-mom.my-subdomain.workers.dev/v1/health"
      );
      // Never reaches here — error 1042 or timeout
      return Response.json({ status: response.status });
    } catch (err) {
      return Response.json({ error: err.message }, { status: 500 });
    }
  },
};

The response you get (if you get one at all) is:

Error 1042: Worker tried to fetch from another Worker on the same zone,
which is not allowed. Check that you have no loops in your routing.

Why It Happens

Cloudflare’s routing layer treats *.workers.dev URLs specially. When a Worker on account X makes a fetch to another *.workers.dev URL on account X, the runtime detects a potential routing loop and blocks the request. This is a safety mechanism to prevent infinite recursion (Worker A calls Worker B calls Worker A calls…).

The check is overly broad — it blocks all same-account *.workers.dev cross-Worker requests, not just actual loops.

The Combination That Kills You

This means both of your fallback strategies can fail simultaneously:

  1. Service binding fails because target exports DO classes
  2. Public URL fetch fails because both Workers are on *.workers.dev

You have no working communication path between your Workers. This is the situation we found ourselves in before discovering the "entrypoint": "default" fix.

The Compatibility Flag

There is a compatibility flag called global_fetch_strictly_public that changes the routing behavior for outbound fetch:

{
  "compatibility_flags": ["global_fetch_strictly_public"]
}

When enabled, the global fetch() function routes requests as if they came from the public internet. However, this flag still does not fix *.workers.dev cross-Worker requests. It only helps when the target Worker has a custom domain:

// With global_fetch_strictly_public enabled:

// STILL FAILS — workers.dev
await fetch("https://api-mom.my-subdomain.workers.dev/v1/health");

// WORKS — custom domain
await fetch("https://api.yourdomain.com/v1/health");

The Custom Domain Fix

The real solution is to assign a custom domain to any Worker that needs to be reachable via public HTTP:

// api-mom/wrangler.jsonc
{
  "name": "api-mom",
  "routes": [
    {
      "pattern": "api-mom.yourdomain.com/*",
      "zone_name": "yourdomain.com"
    }
  ]
}

Now other Workers (or any HTTP client) can call:

await fetch("https://api-mom.yourdomain.com/v1/health");

This works because custom domains go through Cloudflare’s normal HTTP routing, which does not have the same-account restriction that *.workers.dev has.


Diagnosis Checklist

When a service binding call fails, use this diagnostic sequence to identify which problem you are hitting.

Step 1: Verify the Binding Exists

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const diagnostics: Record<string, unknown> = {};

    // Check binding existence
    diagnostics.bindingExists = env.APIMOM !== undefined;
    diagnostics.bindingType = typeof env.APIMOM;
    diagnostics.bindingKeys = env.APIMOM ? Object.keys(env.APIMOM) : [];

    return Response.json(diagnostics, { status: 200 });
  },
};

Expected output if binding is configured correctly:

{
  "bindingExists": true,
  "bindingType": "object",
  "bindingKeys": []
}

Note: Object.keys() returns an empty array because the binding is a Proxy object. This is normal.

If bindingExists is false: Your service binding is not configured. Check your wrangler.jsonc for the correct binding name.

Step 2: Verify the .fetch() Method

diagnostics.fetchExists = typeof env.APIMOM?.fetch === "function";
diagnostics.fetchType = typeof env.APIMOM?.fetch;

Expected output:

{
  "fetchExists": true,
  "fetchType": "function"
}

If .fetch() exists as a function but throws when called, you are hitting the DO export issue.

Step 3: Attempt the Call with Error Capture

try {
  const response = await env.APIMOM.fetch("http://api-mom/health");
  diagnostics.fetchStatus = response.status;
  diagnostics.fetchOk = response.ok;
  const text = await response.text();
  diagnostics.fetchBody = text.slice(0, 500);
} catch (err) {
  diagnostics.fetchError = err instanceof Error ? err.message : String(err);
  diagnostics.fetchErrorStack = err instanceof Error ? err.stack : undefined;
}

Step 4: Check the Target Worker’s Exports

This is the critical step. You cannot do this at runtime — you must inspect the target Worker’s source code.

Look for:

// THESE exports cause the problem:
export { CapabilityRegistryDO } from "./capability-registry-do";
export { RunnerDO } from "./runner-do";
export class SomeDO extends DurableObject { ... }
export class SomeEntrypoint extends WorkerEntrypoint { ... }

// THIS export is fine:
export default { ... };

If the target has ANY named class exports (Durable Object or WorkerEntrypoint), your plain .fetch() call will break.

Step 5: Full Diagnostic Handler

Here is a complete diagnostic endpoint you can add to any Worker:

interface Env {
  APIMOM: Fetcher;
  UBERMESH: Fetcher;
}

async function diagnoseBindings(env: Env): Promise<Record<string, unknown>> {
  const results: Record<string, unknown> = {};

  const bindings = [
    { name: "APIMOM", binding: env.APIMOM },
    { name: "UBERMESH", binding: env.UBERMESH },
  ];

  for (const { name, binding } of bindings) {
    const diag: Record<string, unknown> = {
      exists: binding !== undefined,
      type: typeof binding,
      fetchType: typeof binding?.fetch,
    };

    if (typeof binding?.fetch === "function") {
      try {
        const start = Date.now();
        const response = await binding.fetch(`http://${name.toLowerCase()}/health`);
        diag.latencyMs = Date.now() - start;
        diag.status = response.status;
        diag.ok = response.ok;
        diag.contentType = response.headers.get("content-type");
        const body = await response.text();
        diag.bodyPreview = body.slice(0, 200);
      } catch (err) {
        diag.error = err instanceof Error ? err.message : String(err);
        diag.errorName = err instanceof Error ? err.constructor.name : "unknown";

        // Classify the error
        if (diag.error === "Callback returned incorrect type; expected 'Promise'") {
          diag.diagnosis = "TARGET_EXPORTS_DO_CLASSES";
          diag.fix = 'Add "entrypoint": "default" to the service binding config';
        } else if (String(diag.error).includes("1042")) {
          diag.diagnosis = "WORKERS_DEV_SAME_ACCOUNT";
          diag.fix = "Use a service binding instead of public URL fetch, or add a custom domain";
        }
      }
    }

    results[name] = diag;
  }

  return results;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/diagnose") {
      const results = await diagnoseBindings(env);
      return Response.json(results, {
        headers: { "content-type": "application/json" },
      });
    }
    return new Response("OK");
  },
};

Decision Tree

Service binding call fails

├── env.BINDING is undefined
│   └── Check wrangler.jsonc — binding name mismatch

├── typeof env.BINDING.fetch !== "function"
│   └── Binding is not a Fetcher — check if you accidentally bound to a DO namespace

├── .fetch() throws "Callback returned incorrect type; expected 'Promise'"
│   └── TARGET EXPORTS DO/ENTRYPOINT CLASSES
│       ├── Fix A: Add "entrypoint": "default" to binding config
│       ├── Fix B: Move DO exports to a separate Worker
│       └── Fix C: Convert target to WorkerEntrypoint (use RPC)

├── .fetch() throws network/timeout error
│   └── Target Worker is not deployed or has a deployment error

└── .fetch() returns error 1042
    └── WORKERS.DEV ROUTING TRAP
        ├── Fix A: Use a service binding (not public URL fetch)
        └── Fix B: Add a custom domain to the target Worker

Fix Matrix

Every combination of source Worker, target Worker exports, communication method, and expected result:

#SourceTarget ExportsMethodConfigResult
1Workerdefault only.fetch() via service binding{ "binding": "X", "service": "y" }Works
2Workerdefault + DO classes.fetch() via service binding{ "binding": "X", "service": "y" }BROKEN — “Callback returned incorrect type”
3Workerdefault + DO classes.fetch() via service binding{ "binding": "X", "service": "y", "entrypoint": "default" }Works (the fix)
4WorkerWorkerEntrypoint default.fetch() via service binding{ "binding": "X", "service": "y" }Works (WE has .fetch())
5WorkerWorkerEntrypoint default.methodName() via service binding{ "binding": "X", "service": "y" }Works (RPC)
6WorkerNamed WorkerEntrypoint.methodName() via service binding{ "binding": "X", "service": "y", "entrypoint": "MyAPI" }Works (RPC)
7WorkerAnyfetch() to *.workers.dev URLN/ABROKEN — Error 1042 (same account)
8WorkerAnyfetch() to custom domain URLN/AWorks
9WorkerAnyWorkers AI binding"ai": { "binding": "AI" }Works (native binding)
10Workflow stepAny.fetch() via service bindingSame as rows 1-3Same behavior
11Workflow stepAnyWorkers AI binding"ai": { "binding": "AI" }Works (native binding)
12Durable ObjectAny.fetch() via service bindingMust pass env from WorkerSame as rows 1-3
13Pages FunctionAny.fetch() via service bindingPages service binding configSame as rows 1-3

The Critical Row

Row 2 is the one that catches everyone. You have a working system (Row 1). You add a Durable Object to the target Worker. Your system breaks (Row 2). The error message gives you no hint. Row 3 is the fix: add "entrypoint": "default".


Solutions

Solution 1: Add "entrypoint": "default" (Quick Fix)

When to use: You have an existing Worker that exports DO classes and you need HTTP-style service bindings to keep working.

What to do: Add "entrypoint": "default" to every service binding that targets this Worker.

// Before (broken)
{
  "services": [
    { "binding": "APIMOM", "service": "api-mom" }
  ]
}

// After (fixed)
{
  "services": [
    { "binding": "APIMOM", "service": "api-mom", "entrypoint": "default" }
  ]
}

Pros:

Cons:

Solution 2: Move DO Exports to a Separate Worker

When to use: You want clean separation and do not want callers to need special configuration.

What to do: Split your Worker into two: one for the API (default fetch handler only) and one for the Durable Objects.

Before:
  api-mom (exports: default + CapabilityRegistryDO + RunnerDO)

After:
  api-mom        (exports: default only — HTTP API)
  api-mom-dos    (exports: CapabilityRegistryDO + RunnerDO)
// api-mom/src/index.ts — AFTER split
// NO DO exports here
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // ... all HTTP routing logic
  },
};
// api-mom-dos/src/index.ts — new Worker
export { CapabilityRegistryDO } from "./capability-registry-do";
export { RunnerDO } from "./runner-do";

export default {
  async fetch(): Promise<Response> {
    return new Response("This Worker only hosts Durable Objects", { status: 404 });
  },
};
// api-mom-dos/wrangler.jsonc
{
  "name": "api-mom-dos",
  "main": "src/index.ts",
  "durable_objects": {
    "bindings": [
      { "name": "RUNNER_DO", "class_name": "RunnerDO" },
      { "name": "CAPABILITY_REGISTRY", "class_name": "CapabilityRegistryDO" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_classes": ["RunnerDO"] },
    { "tag": "v2", "new_classes": ["CapabilityRegistryDO"] }
  ]
}

Pros:

Cons:

Solution 3: Convert to WorkerEntrypoint (RPC)

When to use: You are willing to change both the target Worker and all calling code. This is the recommended long-term approach.

What to do: Convert the target Worker to extend WorkerEntrypoint and expose methods instead of HTTP endpoints.

// api-mom/src/index.ts — converted to WorkerEntrypoint
import { WorkerEntrypoint } from "cloudflare:workers";
import { createDb, schema } from "./db";
import { eq } from "drizzle-orm";

export { CapabilityRegistryDO } from "./capability-registry-do";
export { RunnerDO } from "./runner-do";

interface Capability {
  name: string;
  provider: string;
  endpoint: string;
}

export default class ApiMom extends WorkerEntrypoint<Env> {
  // HTTP handler — still works for external callers
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/health") {
      return Response.json({ ok: true });
    }
    // ... full HTTP routing
    return new Response("Not Found", { status: 404 });
  }

  // RPC methods — for internal Worker-to-Worker calls
  async health(): Promise<{ ok: boolean; providers: string[] }> {
    return { ok: true, providers: ["openai", "anthropic", "fal"] };
  }

  async getCapability(name: string): Promise<Capability | null> {
    const db = createDb(this.env.DB);
    const result = await db.query.capabilities.findFirst({
      where: eq(schema.capabilities.name, name),
    });
    return result ?? null;
  }

  async proxyRequest(
    provider: string,
    model: string,
    messages: Array<{ role: string; content: string }>
  ): Promise<{ response: string; cost: number }> {
    // ... proxy logic with cost tracking
    return { response: "...", cost: 0.003 };
  }

  async checkBudget(projectId: string): Promise<{
    spent: number;
    limit: number;
    remaining: number;
  }> {
    // ... budget check logic
    return { spent: 12.50, limit: 100, remaining: 87.50 };
  }
}
// caller-worker/src/index.ts — using RPC
interface Env {
  APIMOM: Service<import("api-mom").default>;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Direct method calls — no HTTP, no serialization overhead
    const health = await env.APIMOM.health();
    const cap = await env.APIMOM.getCapability("ubermesh-crud");
    const budget = await env.APIMOM.checkBudget("scram-jet");

    if (budget.remaining < 5) {
      return Response.json({ error: "Budget exhausted" }, { status: 429 });
    }

    return Response.json({ health, capability: cap, budget });
  },
};

Pros:

Cons:

Solution 4: Use Custom Domains for HTTP Communication

When to use: You need HTTP-based communication between Workers and cannot use service bindings (e.g., cross-account, or Workers on different Cloudflare accounts).

// api-mom/wrangler.jsonc
{
  "name": "api-mom",
  "routes": [
    {
      "pattern": "api-mom.yourdomain.com/*",
      "zone_name": "yourdomain.com"
    }
  ]
}
// caller-worker — uses custom domain
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Works because it goes through a custom domain, not *.workers.dev
    const response = await fetch("https://api-mom.yourdomain.com/v1/health");
    return Response.json(await response.json());
  },
};

Pros:

Cons:

Solution 5: Use Workers AI Binding for LLM Calls

When to use: Your Worker-to-Worker call is specifically for AI/LLM inference. Use the native Workers AI binding instead of routing through another Worker.

// wrangler.jsonc
{
  "ai": {
    "binding": "AI"
  }
}
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Direct Workers AI binding — no service binding needed
    const result = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
      messages: [
        { role: "system", content: "You are a helpful assistant." },
        { role: "user", content: "Explain service bindings." },
      ],
    });

    return Response.json(result);
  },
};

Pros:

Cons:


Architecture Recommendations

If you are designing a multi-Worker system on Cloudflare, follow these rules to avoid the pitfalls documented above.

Rule 1: Keep Service Binding Targets Clean

Workers that are consumed by other Workers via service bindings should have minimal exports. Ideally, they export only a default handler or a single WorkerEntrypoint class.

GOOD:
  api-worker      → exports: default { fetch }
  api-worker-dos  → exports: MyDO, OtherDO + default { fetch }

BAD:
  api-worker      → exports: default { fetch } + MyDO + OtherDO

If a Worker needs both Durable Objects and service binding consumers, split it into two Workers or use "entrypoint": "default" on all bindings.

Rule 2: Always Assign Custom Domains

Never rely on *.workers.dev for inter-Worker communication. Every Worker that receives traffic from other Workers should have a custom domain.

// Every production Worker should have this
{
  "routes": [
    {
      "pattern": "service-name.yourdomain.com/*",
      "zone_name": "yourdomain.com"
    }
  ]
}

Custom domains serve as the fallback when service bindings fail, and they are required for cross-account communication.

Rule 3: Use RPC as the Primary Communication Method

For all new Worker-to-Worker communication, use WorkerEntrypoint and RPC. HTTP-style service bindings are the legacy approach.

// All target Workers should extend WorkerEntrypoint
import { WorkerEntrypoint } from "cloudflare:workers";

export default class MyService extends WorkerEntrypoint<Env> {
  async fetch(request: Request): Promise<Response> {
    // Keep HTTP handler for external/debugging access
    return this.handleHTTP(request);
  }

  // RPC methods for internal callers
  async getData(id: string): Promise<Data | null> { ... }
  async processItem(item: Item): Promise<Result> { ... }
}

Rule 4: Reserve HTTP-Style Service Bindings for Legacy Workers

If you cannot refactor a target Worker to use WorkerEntrypoint, use HTTP-style service bindings with "entrypoint": "default" as a defensive measure:

{
  "services": [
    {
      "binding": "LEGACY_SERVICE",
      "service": "old-worker",
      "entrypoint": "default"
    }
  ]
}

Always add "entrypoint": "default" even if the target currently has no DO exports. This protects you against future changes to the target Worker.

Rule 5: Implement Health Checks on All Service Bindings

Every Worker that consumes a service binding should have a diagnostic endpoint:

async function checkBindings(env: Env): Promise<Record<string, BindingHealth>> {
  const bindings: Array<{ name: string; binding: Fetcher }> = [
    { name: "APIMOM", binding: env.APIMOM },
    { name: "UBERMESH", binding: env.UBERMESH },
  ];

  const results: Record<string, BindingHealth> = {};

  for (const { name, binding } of bindings) {
    const start = Date.now();
    try {
      const response = await binding.fetch(`http://${name}/health`);
      results[name] = {
        healthy: response.ok,
        status: response.status,
        latencyMs: Date.now() - start,
      };
    } catch (err) {
      results[name] = {
        healthy: false,
        error: err instanceof Error ? err.message : String(err),
        latencyMs: Date.now() - start,
      };
    }
  }

  return results;
}

interface BindingHealth {
  healthy: boolean;
  status?: number;
  error?: string;
  latencyMs: number;
}

Patterns for Production Multi-Worker Systems

Pattern 1: The Gateway Pattern

One Worker serves as the public HTTP gateway. All other Workers are internal, accessed only via service bindings or RPC.

Internet → Gateway Worker (custom domain)
              ├── env.AUTH → Auth Worker (RPC)
              ├── env.API → API Worker (RPC)
              └── env.DATA → Data Worker (RPC)
// gateway/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

interface Env {
  AUTH: Service<import("../auth").default>;
  API: Service<import("../api").default>;
  DATA: Service<import("../data").default>;
}

export default class Gateway extends WorkerEntrypoint<Env> {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    // Auth check via RPC
    const token = request.headers.get("Authorization")?.replace("Bearer ", "");
    if (!token) {
      return Response.json({ error: "Unauthorized" }, { status: 401 });
    }

    const session = await this.env.AUTH.validateToken(token);
    if (!session) {
      return Response.json({ error: "Invalid token" }, { status: 403 });
    }

    // Route to internal services via RPC
    if (url.pathname.startsWith("/api/")) {
      return this.env.API.fetch(request);
    }

    if (url.pathname.startsWith("/data/")) {
      const result = await this.env.DATA.query(
        url.searchParams.get("q") ?? "",
        session.userId
      );
      return Response.json(result);
    }

    return new Response("Not Found", { status: 404 });
  }
}

Pattern 2: The Sidecar Pattern

A Worker hosts Durable Objects that provide state management for other Workers. The DO Worker is never called via service binding .fetch() — instead, callers get a DO namespace binding and interact with individual DOs.

// do-sidecar/src/index.ts
import { DurableObject } from "cloudflare:workers";

export class SessionDO extends DurableObject {
  private data: Map<string, unknown> = new Map();

  async get(key: string): Promise<unknown> {
    return this.data.get(key) ?? (await this.ctx.storage.get(key));
  }

  async set(key: string, value: unknown): Promise<void> {
    this.data.set(key, value);
    await this.ctx.storage.put(key, value);
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    if (request.method === "GET") {
      const value = await this.get(url.searchParams.get("key") ?? "");
      return Response.json({ value });
    }
    if (request.method === "PUT") {
      const { key, value } = await request.json<{ key: string; value: unknown }>();
      await this.set(key, value);
      return Response.json({ ok: true });
    }
    return new Response("Method not allowed", { status: 405 });
  }
}

// Minimal default export — this Worker is not meant to be called directly
export default {
  async fetch(): Promise<Response> {
    return new Response("Use DO bindings, not direct fetch", { status: 404 });
  },
};
// caller-worker/wrangler.jsonc
{
  "services": [
    // DO NOT bind to the sidecar Worker via service binding
    // Instead, use a DO namespace binding (configured in the sidecar's wrangler.jsonc
    // and referenced here via script_name)
  ],
  "durable_objects": {
    "bindings": [
      {
        "name": "SESSIONS",
        "class_name": "SessionDO",
        "script_name": "do-sidecar"
      }
    ]
  }
}
// caller-worker/src/index.ts
interface Env {
  SESSIONS: DurableObjectNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Access a specific DO instance by ID
    const sessionId = env.SESSIONS.idFromName("user-123");
    const session = env.SESSIONS.get(sessionId);

    // Call the DO's fetch handler
    const response = await session.fetch("http://session/?key=theme");
    const data = await response.json<{ value: unknown }>();

    return Response.json({ theme: data.value });
  },
};

Key insight: DO namespace bindings (script_name in DO config) do not suffer from the same export-poisoning issue as service bindings. You are binding to a specific DO class, not to the Worker’s default export.

Pattern 3: The Facade Pattern

When you have an existing Worker with DO exports and many callers, create a thin facade Worker that proxies requests. The facade has no DO exports and provides a clean service binding target.

// api-mom-facade/src/index.ts
// NO DO exports — this is a clean service binding target

interface Env {
  REAL_API_MOM: Fetcher; // Points to the real api-mom via custom domain
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Proxy everything to the real API Mom
    const url = new URL(request.url);
    const targetUrl = `https://api-mom.yourdomain.com${url.pathname}${url.search}`;

    return fetch(targetUrl, {
      method: request.method,
      headers: request.headers,
      body: request.body,
    });
  },
};

This is a temporary pattern — use it while you migrate to RPC.

Pattern 4: The Hybrid Pattern (Our Actual Architecture)

In practice, you often need both HTTP (for external callers) and RPC (for internal callers) on the same Worker. The WorkerEntrypoint class supports this:

// api-mom/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

export { CapabilityRegistryDO } from "./capability-registry-do";
export { RunnerDO } from "./runner-do";

export default class ApiMom extends WorkerEntrypoint<Env> {
  // HTTP handler — for external callers, curl, browsers
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/health") {
      const health = await this.health();
      return Response.json(health);
    }

    if (url.pathname.startsWith("/v1/capabilities/")) {
      const name = url.pathname.split("/").pop() ?? "";
      const cap = await this.getCapability(name);
      if (!cap) return Response.json({ error: "Not found" }, { status: 404 });
      return Response.json(cap);
    }

    return new Response("Not Found", { status: 404 });
  }

  // RPC methods — for internal Worker-to-Worker calls
  async health(): Promise<{ ok: boolean; providers: string[] }> {
    return { ok: true, providers: ["openai", "anthropic", "fal"] };
  }

  async getCapability(name: string): Promise<Capability | null> {
    const db = createDb(this.env.DB);
    return db.query.capabilities.findFirst({
      where: eq(schema.capabilities.name, name),
    }) ?? null;
  }

  async proxy(provider: string, payload: LLMRequest): Promise<LLMResponse> {
    // ... cost-tracked proxy logic
  }
}

The WorkerEntrypoint class can coexist with DO class exports without breaking. When callers use RPC methods (await env.APIMOM.health()), everything works. When callers use .fetch(), it calls the fetch method on the class, which also works.

The key difference: WorkerEntrypoint’s .fetch() is a class method, not a module-level handler on an object literal. The runtime knows how to dispatch both RPC and HTTP calls to it.


Small Examples

Example 1: Defensive Service Binding Configuration

Always add "entrypoint": "default" to service bindings as a defensive measure. This costs nothing and protects against future DO additions to the target.

// wrangler.jsonc — defensive configuration
{
  "services": [
    // DEFENSIVE: always specify entrypoint even for clean targets
    { "binding": "AUTH", "service": "auth-worker", "entrypoint": "default" },
    { "binding": "API", "service": "api-worker", "entrypoint": "default" },
    { "binding": "DATA", "service": "data-worker", "entrypoint": "default" },
    // RPC: use named entrypoint for specific API surface
    { "binding": "ADMIN", "service": "api-worker", "entrypoint": "AdminAPI" }
  ]
}

Example 2: Type-Safe RPC Binding

Generate types for your RPC bindings with wrangler types:

# In the calling Worker's directory
npx wrangler types

This generates a .d.ts file with the types of all bound Workers. Use them:

// Generated by wrangler types
interface Env {
  API: Service<import("../api-worker/src/index").default>;
}

// Usage — fully type-checked
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // TypeScript knows getCapability takes a string and returns Capability | null
    const cap = await env.API.getCapability("text-to-speech");

    // TypeScript error: Property 'nonExistentMethod' does not exist
    // await env.API.nonExistentMethod();

    return Response.json(cap);
  },
};

Example 3: Fallback Chain for Unreliable Bindings

When you cannot guarantee a service binding will work, implement a fallback chain:

interface Env {
  APIMOM: Fetcher;
  APIMOM_URL: string; // Custom domain URL as fallback
}

async function callApiMom(
  env: Env,
  path: string,
  options?: RequestInit
): Promise<Response> {
  // Try 1: Service binding
  try {
    const response = await env.APIMOM.fetch(`http://api-mom${path}`, options);
    if (response.ok) return response;
  } catch {
    console.log("Service binding failed, trying custom domain...");
  }

  // Try 2: Custom domain (requires APIMOM_URL env var)
  if (env.APIMOM_URL) {
    try {
      const response = await fetch(`${env.APIMOM_URL}${path}`, options);
      if (response.ok) return response;
    } catch {
      console.log("Custom domain also failed");
    }
  }

  // All paths failed
  return Response.json(
    { error: "All communication paths to API Mom failed" },
    { status: 502 }
  );
}

Example 4: Service Binding with Request Cloning

When forwarding an incoming request through a service binding, you need to clone it because the body can only be read once:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Forward /api/* to the API Worker
    if (url.pathname.startsWith("/api/")) {
      // Clone the request — body can only be consumed once
      const apiRequest = new Request(
        `http://api${url.pathname}${url.search}`,
        {
          method: request.method,
          headers: request.headers,
          body: request.body,
          // Preserve duplex for streaming bodies
          ...(request.body ? { duplex: "half" as const } : {}),
        }
      );

      return env.API_WORKER.fetch(apiRequest);
    }

    return new Response("Not Found", { status: 404 });
  },
};

Example 5: Multi-Binding Health Dashboard

A dedicated endpoint that reports the health of all service bindings:

interface Env {
  APIMOM: Fetcher;
  UBERMESH: Fetcher;
  GATHERFEED: Fetcher;
  MEDIA_STORE: Fetcher;
}

const BINDING_NAMES: Array<keyof Env> = [
  "APIMOM",
  "UBERMESH",
  "GATHERFEED",
  "MEDIA_STORE",
];

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname !== "/health/bindings") {
      return new Response("OK");
    }

    const checks = BINDING_NAMES.map(async (name) => {
      const binding = env[name] as Fetcher;
      const start = Date.now();
      try {
        const res = await binding.fetch(`http://${name.toLowerCase()}/health`);
        return {
          name,
          status: "up" as const,
          httpStatus: res.status,
          latencyMs: Date.now() - start,
        };
      } catch (err) {
        return {
          name,
          status: "down" as const,
          error: err instanceof Error ? err.message : String(err),
          latencyMs: Date.now() - start,
        };
      }
    });

    const results = await Promise.allSettled(checks);
    const statuses = results.map((r) =>
      r.status === "fulfilled" ? r.value : { name: "unknown", status: "error" }
    );

    const allHealthy = statuses.every((s) => s.status === "up");

    return Response.json(
      { healthy: allHealthy, bindings: statuses },
      { status: allHealthy ? 200 : 503 }
    );
  },
};

Example 6: RPC with Structured Error Handling

RPC calls can throw. Wrap them with structured error handling:

class ServiceError extends Error {
  constructor(
    public readonly service: string,
    public readonly method: string,
    public readonly cause: unknown
  ) {
    super(`${service}.${method} failed: ${cause}`);
    this.name = "ServiceError";
  }
}

async function rpc<T>(
  service: string,
  method: string,
  fn: () => Promise<T>
): Promise<T> {
  try {
    return await fn();
  } catch (err) {
    throw new ServiceError(service, method, err);
  }
}

// Usage
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    try {
      const health = await rpc("APIMOM", "health", () => env.APIMOM.health());
      const cap = await rpc("APIMOM", "getCapability", () =>
        env.APIMOM.getCapability("tts")
      );
      return Response.json({ health, capability: cap });
    } catch (err) {
      if (err instanceof ServiceError) {
        return Response.json(
          {
            error: err.message,
            service: err.service,
            method: err.method,
          },
          { status: 502 }
        );
      }
      throw err;
    }
  },
};

Example 7: Cross-Worker DO Access via script_name

Access Durable Objects defined in another Worker without service bindings:

// caller/wrangler.jsonc
{
  "durable_objects": {
    "bindings": [
      {
        "name": "REMOTE_SESSIONS",
        "class_name": "SessionDO",
        "script_name": "session-worker"
      }
    ]
  }
}
// caller/src/index.ts
interface Env {
  REMOTE_SESSIONS: DurableObjectNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const id = env.REMOTE_SESSIONS.idFromName("session-abc");
    const stub = env.REMOTE_SESSIONS.get(id);

    // Call the DO's RPC methods (if it extends DurableObject from cloudflare:workers)
    const data = await stub.getData();

    // Or call its fetch handler
    const response = await stub.fetch("http://do/status");

    return Response.json({ data, status: await response.json() });
  },
};

Example 8: Wrangler.jsonc Service Binding with Environment Overrides

Different service binding targets per environment:

{
  "name": "my-worker",
  "main": "src/index.ts",
  "services": [
    { "binding": "APIMOM", "service": "api-mom", "entrypoint": "default" }
  ],
  "env": {
    "staging": {
      "services": [
        { "binding": "APIMOM", "service": "api-mom-staging", "entrypoint": "default" }
      ]
    },
    "production": {
      "services": [
        { "binding": "APIMOM", "service": "api-mom", "entrypoint": "default" }
      ]
    }
  }
}

Comparisons: Worker-to-Worker Communication Methods

Method Comparison

FeaturePublic URL FetchService Binding (.fetch())RPC (WorkerEntrypoint)DO Namespace BindingQueue
Latency~1-5ms (network)~0ms (same thread)~0ms (same thread)~0ms (same colo)Async (seconds)
Cost1 subrequestFreeFreeFree$0.40/million
Auth requiredYesNoNoNoNo
Type safetyNoNoYesPartial (RPC on DO)No
Sync/AsyncSync (req/res)Sync (req/res)Sync (call/return)Sync (call/return)Async (fire/forget)
Data formatHTTPHTTPJS objectsJS objects / HTTPMessageBatch
Cross-accountYesNoNoNoNo
Max payload100MB100MB~128MB (V8 limit)~128MB (V8 limit)128KB/message
Retry built-inNoNoNoNoYes (3 retries)
DO export safeN/ANoYesN/AN/A
*.workers.devNo (same acct)Yes*Yes*YesYes

*With the caveats documented above.

When to Use What

ScenarioRecommended MethodWhy
New Worker-to-Worker callRPCType-safe, zero-cost, future-proof
Legacy Worker cannot changeService binding + "entrypoint": "default"Minimal change, works with DO exports
Cross-account callPublic URL + custom domainOnly option for cross-account
Fire-and-forget async workQueueBuilt-in retry, back-pressure
Access remote DO stateDO namespace binding (script_name)Direct DO access, no intermediary
External webhook/API callPublic URL + custom domainStandard HTTP
AI/LLM inferenceWorkers AI bindingNative, free tier, no routing needed

Platform Comparison: Worker-to-Worker Communication

How other platforms handle the equivalent problem:

PlatformMechanismType SafetyCostGotchas
Cloudflare WorkersService Bindings / RPCYes (RPC)FreeDO exports break .fetch(); *.workers.dev routing
AWS LambdaInvoke API / Step FunctionsNo (JSON)Per-invocationCold starts; max 15min timeout
Vercel FunctionsHTTP calls onlyNoPer-requestNo native binding; must go through HTTP
Deno DeployBroadcastChannel / Deno KVPartialFree (KV reads)No direct function-to-function call
Fly.ioInternal DNS / Machines APINoPer-requestMust manage DNS; no zero-cost option
Azure FunctionsDurable Functions / Service BusPartialPer-executionComplex orchestration model
Google Cloud FunctionsHTTP / Pub/Sub / EventarcNoPer-invocationNo same-thread optimization

Key insight: Cloudflare’s RPC system is genuinely unique among serverless platforms. Running two Workers on the same thread with zero-cost function calls is something no other platform offers. The DO export bug is painful, but the underlying capability is exceptional.


Anti-Patterns

Don’tDo InsteadWhy
Use *.workers.dev URLs for Worker-to-Worker fetchUse service bindings or custom domainsError 1042 blocks same-account *.workers.dev cross-fetch
Omit "entrypoint": "default" when target has DO exportsAlways add "entrypoint": "default" for HTTP-style bindingsSilent “Callback returned incorrect type” error
Debug “Callback returned incorrect type” by checking your code logicCheck the target Worker’s exports for DO/Entrypoint classesThe error is not in your code — it is in the binding dispatch
Use HTTP-style service bindings for new projectsUse RPC (WorkerEntrypoint)RPC is type-safe, more expressive, and handles DO exports correctly
Assume service bindings are symmetricTest each direction independentlyWorker A->B may work while B->A breaks (different exports)
Put Durable Objects and API handlers in the same module without considerationEither split into separate Workers or use WorkerEntrypointMixing DO exports with service binding targets causes the bug
Rely on a single communication pathImplement fallback (binding -> custom domain)Bindings can fail; custom domains are the reliable fallback
Skip health checks on service bindingsAdd /health endpoint + binding diagnosticYou need fast detection of binding failures
Use Object.keys(env.BINDING) to inspect bindingsUse typeof env.BINDING and typeof env.BINDING.fetchBindings are Proxies; Object.keys returns empty array
Assume all bindings behave the sameTest each binding individually in a diagnostic endpointDifferent targets have different export profiles
Put fetch() calls to other Workers in hot paths without timeoutWrap with Promise.race or AbortControllerA broken binding can hang your Worker
Hardcode Worker URLs in environment variablesUse service bindings for same-account; env vars for cross-account onlyService bindings are zero-cost and avoid DNS/routing issues

This section documents the actual system where we discovered these issues. These are real production Workers deployed on Cloudflare.

The Players

API Mom (garywu/api-mom) — Unified egress API gateway. Routes LLM calls to OpenAI, Anthropic, and other providers. Manages cost budgets, capability registry, and runner connections. Hosts two Durable Objects: CapabilityRegistryDO (provider routing) and RunnerDO (WebSocket tunnels to local machines).

// api-mom/src/index.ts — the exports that cause the problem
export { CapabilityRegistryDO } from "./capability-registry-do";
export { RunnerDO } from "./runner-do";

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // Full HTTP API: /health, /v1/openai/*, /v1/anthropic/*, /v1/capabilities/*
    // /v1/budget, /v1/machines/*, /v1/claude/:machine/*, /connect, /dispatch
  },
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
    // Daily metrics rollup, heartbeat checks
  },
  async queue(batch: MessageBatch): Promise<void> {
    // Indexing queue consumer
  },
};

UberMesh (garywu/ubermesh) — Content-addressed storage substrate. D1 + R2 + KV + Vectorize. No Durable Objects exported from main module.

// ubermesh/packages/api/src/index.ts — clean exports
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // CRUD API for 59 content types, search, graph queries
  },
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
    // Scheduled maintenance
  },
};

Scram Jet (garywu/scram-jet) — Pipeline orchestration engine. Calls API Mom for LLM routing and UberMesh for content storage.

// scram-jet/packages/engine/wrangler.jsonc
{
  "services": [
    { "binding": "MEDIA_STORE", "service": "media-store", "entrypoint": "default" },
    { "binding": "APIMOM", "service": "api-mom", "entrypoint": "default" }
  ]
}

Note the "entrypoint": "default" on both bindings. This is the fix.

Gallery (ubermesh/apps/gallery) — Content browser UI. Calls UberMesh for data.

// ubermesh/apps/gallery/wrangler.jsonc
{
  "services": [{ "binding": "UBERMESH", "service": "ubermesh" }]
}

No "entrypoint": "default" needed because UberMesh has no DO exports.

Book Telic (garywu/book-telic) — Publishing platform. Calls both UberMesh and API Mom.

// book-telic/wrangler.jsonc
{
  "services": [
    { "binding": "UBERMESH", "service": "ubermesh" },
    { "binding": "API_MOM", "service": "api-mom" }
  ]
}

The API_MOM binding here does NOT have "entrypoint": "default". This means Book Telic’s calls to API Mom via service binding .fetch() are broken (or were broken at the time of discovery). The UBERMESH binding works fine because UberMesh has no DO exports.

The Discovery Timeline

  1. Scram Jet calls API Mom — Service binding configured without "entrypoint". Call to env.APIMOM.fetch() fails with “Callback returned incorrect type; expected ‘Promise’”.

  2. Gallery calls UberMesh — Service binding configured identically (no "entrypoint"). Call to env.UBERMESH.fetch() works perfectly.

  3. Difference identified — API Mom exports CapabilityRegistryDO and RunnerDO at module level. UberMesh does not export any DO classes from its main module.

  4. Root cause confirmed — Removing the DO class exports from API Mom’s index.ts fixes the service binding. Adding them back breaks it.

  5. Workaround found — Adding "entrypoint": "default" to the service binding configuration in Scram Jet’s wrangler.jsonc fixes the issue without changing API Mom.

  6. workers.dev issue discovered separately — When attempting to fall back to fetch("https://apimom-router.apiservices.workers.dev/..."), error 1042 was encountered.

The Binding Map

Service Binding Map (as of April 2026)

Scram Jet ──(APIMOM, entrypoint: default)──→ API Mom ←── exports DO classes
    │                                            ↑
    └──(MEDIA_STORE, entrypoint: default)──→ Media Store
                                                 
Gallery ──(UBERMESH, no entrypoint needed)──→ UberMesh ←── NO DO exports

Book Telic ──(UBERMESH)──→ UberMesh          (works)

    └──(API_MOM, NO entrypoint)──→ API Mom   (BROKEN)

UberMesh ──(APIMOM, no entrypoint)──→ API Mom  (BROKEN — needs fix)

API Mom ──(BROWSER_AGENT)──→ Browser Agent
    │──(GATHERFEED)──→ GatherFeed API
    └──(SCALABLE_MEDIA)──→ Scalable Media API

What Needs Fixing

Based on this analysis:

WorkerBindingTargetHas entrypoint: default?Status
Scram JetAPIMOMapi-momYesFixed
Scram JetMEDIA_STOREmedia-storeYesFixed
Book TelicAPI_MOMapi-momNoNeeds fix
Book TelicUBERMESHubermeshNo (not needed)Works
UberMeshAPIMOMapi-momNoNeeds fix
GalleryUBERMESHubermeshNo (not needed)Works

Timeline of Discovery

This is a chronological record of how we discovered and resolved these issues. Included for other teams hitting similar problems.

We built the Gallery app with a service binding to UberMesh. Everything worked. We assumed service bindings were straightforward.

Phase 2: “Why Does Scram Jet Fail?”

Scram Jet needed to call API Mom for capability routing. Same pattern: service binding + .fetch(). Immediate failure with “Callback returned incorrect type; expected ‘Promise’”.

First hypothesis: authentication issue. Wrong — the error occurs before any auth check.

Second hypothesis: URL format. Tried various hostnames, paths, methods. All fail the same way.

Third hypothesis: binding misconfiguration. Verified the binding name, service name, deployment status. All correct.

Phase 3: “What’s Different About API Mom?”

Compared API Mom’s index.ts with UberMesh’s index.ts. Both have the same default export structure. The difference: API Mom has export { CapabilityRegistryDO } and export { RunnerDO }.

Removed the DO exports temporarily. Service binding works. Added them back. Service binding breaks.

Phase 4: “The Entrypoint Fix”

Searched Cloudflare docs for any mention of entrypoint in service binding configuration. Found it in the RPC docs, not the service binding docs. Adding "entrypoint": "default" to the service binding config forces the runtime to use the default export’s fetch handler, bypassing the RPC dispatch path.

Phase 5: “The workers.dev Trap”

While debugging the service binding issue, we tried falling back to a public URL fetch. Discovered that *.workers.dev URLs are blocked for same-account cross-Worker fetch. This compounded the problem — both communication paths were broken.

Phase 6: “Document Everything”

Wrote this article so no one else has to spend the same hours debugging.


References

Official Cloudflare Documentation

Cloudflare Blog Posts

Community Reports and Issues

GitHub Issues (workers-sdk)

Internal References (Atlas Ecosystem)


Written April 2026. Based on production experience with Cloudflare Workers, Wrangler v3.x, and workerd. These behaviors are undocumented as of the writing date and may be fixed in future versions of the platform. If Cloudflare fixes the silent dispatch path switching, the "entrypoint": "default" workaround will become unnecessary — but it will never break anything, so leave it in your configs as a defensive measure.


Edit page
Share this post on:

Previous Post
The Harness Is a Prompt Compiler
Next Post
Inversion of Control in Data Pipeline Architecture