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
- How Cloudflare Workers communicate with each other (three mechanisms, each with different failure modes)
- The silent breaking change when a Worker exports Durable Object classes alongside its default handler
- Why
*.workers.devURLs cannot be used for Worker-to-Worker HTTP calls on the same account - A complete diagnosis checklist for service binding failures
- The fix matrix: every combination of source, target, and method, with what works and what breaks
- Architecture recommendations for multi-Worker systems that avoid these traps entirely
Table of Contents
Open Table of Contents
- The Problem
- Three Ways Workers Talk to Each Other
- The Silent Breaking Change: DO Exports Poison Service Bindings
- The workers.dev Routing Trap
- Diagnosis Checklist
- Fix Matrix
- Solutions
- Architecture Recommendations
- Patterns for Production Multi-Worker Systems
- Small Examples
- Example 1: Defensive Service Binding Configuration
- Example 2: Type-Safe RPC Binding
- Example 3: Fallback Chain for Unreliable Bindings
- Example 4: Service Binding with Request Cloning
- Example 5: Multi-Binding Health Dashboard
- Example 6: RPC with Structured Error Handling
- Example 7: Cross-Worker DO Access via script_name
- Example 8: Wrangler.jsonc Service Binding with Environment Overrides
- Comparisons: Worker-to-Worker Communication Methods
- Anti-Patterns
- The Case Study: API Mom, UberMesh, Scram Jet, Gallery
- Timeline of Discovery
- References
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:
| Property | Value |
|---|---|
| Latency | Extra network hop (edge routing) |
| Cost | Counts as a subrequest (50 per invocation limit) |
| Authentication | Must be implemented in Worker B |
| Type safety | None (raw HTTP) |
| Failure mode | Network errors, DNS resolution, Workers limit errors |
Key insight: Public URL fetch seems like the simplest path, but it has a critical limitation on
*.workers.devdomains 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:
| Property | Value |
|---|---|
| Latency | Zero — runs on the same thread |
| Cost | Zero — does not count as a subrequest |
| Authentication | Not needed (binding = authorization) |
| Type safety | None (raw HTTP Request/Response) |
| Failure mode | Breaks 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:
| Property | Value |
|---|---|
| Latency | Zero — runs on the same thread |
| Cost | Zero — does not count as a subrequest |
| Authentication | Not needed (binding = authorization) |
| Type safety | Full TypeScript types via wrangler types |
| Failure mode | Clean errors if method does not exist |
| Compatibility | Requires 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
| Feature | Public URL Fetch | Service Binding (.fetch()) | RPC (WorkerEntrypoint) |
|---|---|---|---|
| Latency | Network hop | Zero | Zero |
| Cost per call | 1 subrequest | Free | Free |
| Auth needed | Yes | No | No |
| Type safety | No | No | Yes |
| Works across accounts | Yes | No | No |
| Works on *.workers.dev | No (same account) | Conditional | Conditional |
| Handles DO exports | N/A | No | Yes |
| Configuration | None | Service binding | Service binding |
| Data format | HTTP Request/Response | HTTP Request/Response | JavaScript 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:
- Service bindings
- Durable Object exports
- RPC vs HTTP dispatch
- The actual root cause
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:
-
HTTP dispatch — used when the target Worker has only a default export (object literal with
fetch/scheduled/queuehandlers). The binding provides aFetcherwith a working.fetch()method. -
RPC dispatch — used when the target Worker has any named class exports that the runtime recognizes as entrypoints. This includes
WorkerEntrypointsubclasses and, critically,DurableObjectsubclasses 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:
- Service binding fails because target exports DO classes
- 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:
| # | Source | Target Exports | Method | Config | Result |
|---|---|---|---|---|---|
| 1 | Worker | default only | .fetch() via service binding | { "binding": "X", "service": "y" } | Works |
| 2 | Worker | default + DO classes | .fetch() via service binding | { "binding": "X", "service": "y" } | BROKEN — “Callback returned incorrect type” |
| 3 | Worker | default + DO classes | .fetch() via service binding | { "binding": "X", "service": "y", "entrypoint": "default" } | Works (the fix) |
| 4 | Worker | WorkerEntrypoint default | .fetch() via service binding | { "binding": "X", "service": "y" } | Works (WE has .fetch()) |
| 5 | Worker | WorkerEntrypoint default | .methodName() via service binding | { "binding": "X", "service": "y" } | Works (RPC) |
| 6 | Worker | Named WorkerEntrypoint | .methodName() via service binding | { "binding": "X", "service": "y", "entrypoint": "MyAPI" } | Works (RPC) |
| 7 | Worker | Any | fetch() to *.workers.dev URL | N/A | BROKEN — Error 1042 (same account) |
| 8 | Worker | Any | fetch() to custom domain URL | N/A | Works |
| 9 | Worker | Any | Workers AI binding | "ai": { "binding": "AI" } | Works (native binding) |
| 10 | Workflow step | Any | .fetch() via service binding | Same as rows 1-3 | Same behavior |
| 11 | Workflow step | Any | Workers AI binding | "ai": { "binding": "AI" } | Works (native binding) |
| 12 | Durable Object | Any | .fetch() via service binding | Must pass env from Worker | Same as rows 1-3 |
| 13 | Pages Function | Any | .fetch() via service binding | Pages service binding config | Same 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:
- One-line fix
- No changes to the target Worker
- No changes to calling code
Cons:
- Every caller must know to add this
- Easy to forget when adding new bindings
- Does not solve the underlying architectural issue
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:
- Clean separation of concerns
- Service bindings to the API Worker work without special config
- DO Worker can have its own scaling and observability
Cons:
- The API Worker needs its own binding to the DO Worker to access DOs
- More Workers to manage and deploy
- Durable Object migrations are tied to the Worker that defines them
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:
- Type-safe: TypeScript knows exactly what methods are available and what they return
- No HTTP overhead: JavaScript objects are passed directly, no serialization
- Clean API: methods are more expressive than URL paths
- DO exports do not interfere (they are separate from the entrypoint class)
- Cloudflare’s recommended approach
Cons:
- Requires changing all calling code
- Target Worker must be refactored from object literal to class
- External callers (non-Workers) still need the HTTP handler
- Debugging is harder (no HTTP traces in logs)
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:
- Works regardless of DO exports
- Works regardless of
*.workers.devrestrictions - Standard HTTP — works from anywhere
Cons:
- Extra latency (network hop)
- Costs subrequests
- Requires DNS configuration
- Must implement authentication
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:
- Native binding — always works, no service binding issues
- Free tier available (Workers AI has generous free limits)
- No Worker-to-Worker communication needed
- Built-in to the Cloudflare platform
Cons:
- Only for AI/LLM workloads
- Limited to Cloudflare’s model catalog
- Cannot use external providers (OpenAI, Anthropic) through this binding
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_namein 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
| Feature | Public URL Fetch | Service Binding (.fetch()) | RPC (WorkerEntrypoint) | DO Namespace Binding | Queue |
|---|---|---|---|---|---|
| Latency | ~1-5ms (network) | ~0ms (same thread) | ~0ms (same thread) | ~0ms (same colo) | Async (seconds) |
| Cost | 1 subrequest | Free | Free | Free | $0.40/million |
| Auth required | Yes | No | No | No | No |
| Type safety | No | No | Yes | Partial (RPC on DO) | No |
| Sync/Async | Sync (req/res) | Sync (req/res) | Sync (call/return) | Sync (call/return) | Async (fire/forget) |
| Data format | HTTP | HTTP | JS objects | JS objects / HTTP | MessageBatch |
| Cross-account | Yes | No | No | No | No |
| Max payload | 100MB | 100MB | ~128MB (V8 limit) | ~128MB (V8 limit) | 128KB/message |
| Retry built-in | No | No | No | No | Yes (3 retries) |
| DO export safe | N/A | No | Yes | N/A | N/A |
| *.workers.dev | No (same acct) | Yes* | Yes* | Yes | Yes |
*With the caveats documented above.
When to Use What
| Scenario | Recommended Method | Why |
|---|---|---|
| New Worker-to-Worker call | RPC | Type-safe, zero-cost, future-proof |
| Legacy Worker cannot change | Service binding + "entrypoint": "default" | Minimal change, works with DO exports |
| Cross-account call | Public URL + custom domain | Only option for cross-account |
| Fire-and-forget async work | Queue | Built-in retry, back-pressure |
| Access remote DO state | DO namespace binding (script_name) | Direct DO access, no intermediary |
| External webhook/API call | Public URL + custom domain | Standard HTTP |
| AI/LLM inference | Workers AI binding | Native, free tier, no routing needed |
Platform Comparison: Worker-to-Worker Communication
How other platforms handle the equivalent problem:
| Platform | Mechanism | Type Safety | Cost | Gotchas |
|---|---|---|---|---|
| Cloudflare Workers | Service Bindings / RPC | Yes (RPC) | Free | DO exports break .fetch(); *.workers.dev routing |
| AWS Lambda | Invoke API / Step Functions | No (JSON) | Per-invocation | Cold starts; max 15min timeout |
| Vercel Functions | HTTP calls only | No | Per-request | No native binding; must go through HTTP |
| Deno Deploy | BroadcastChannel / Deno KV | Partial | Free (KV reads) | No direct function-to-function call |
| Fly.io | Internal DNS / Machines API | No | Per-request | Must manage DNS; no zero-cost option |
| Azure Functions | Durable Functions / Service Bus | Partial | Per-execution | Complex orchestration model |
| Google Cloud Functions | HTTP / Pub/Sub / Eventarc | No | Per-invocation | No 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’t | Do Instead | Why |
|---|---|---|
Use *.workers.dev URLs for Worker-to-Worker fetch | Use service bindings or custom domains | Error 1042 blocks same-account *.workers.dev cross-fetch |
Omit "entrypoint": "default" when target has DO exports | Always add "entrypoint": "default" for HTTP-style bindings | Silent “Callback returned incorrect type” error |
| Debug “Callback returned incorrect type” by checking your code logic | Check the target Worker’s exports for DO/Entrypoint classes | The error is not in your code — it is in the binding dispatch |
| Use HTTP-style service bindings for new projects | Use RPC (WorkerEntrypoint) | RPC is type-safe, more expressive, and handles DO exports correctly |
| Assume service bindings are symmetric | Test each direction independently | Worker A->B may work while B->A breaks (different exports) |
| Put Durable Objects and API handlers in the same module without consideration | Either split into separate Workers or use WorkerEntrypoint | Mixing DO exports with service binding targets causes the bug |
| Rely on a single communication path | Implement fallback (binding -> custom domain) | Bindings can fail; custom domains are the reliable fallback |
| Skip health checks on service bindings | Add /health endpoint + binding diagnostic | You need fast detection of binding failures |
Use Object.keys(env.BINDING) to inspect bindings | Use typeof env.BINDING and typeof env.BINDING.fetch | Bindings are Proxies; Object.keys returns empty array |
| Assume all bindings behave the same | Test each binding individually in a diagnostic endpoint | Different targets have different export profiles |
Put fetch() calls to other Workers in hot paths without timeout | Wrap with Promise.race or AbortController | A broken binding can hang your Worker |
| Hardcode Worker URLs in environment variables | Use service bindings for same-account; env vars for cross-account only | Service bindings are zero-cost and avoid DNS/routing issues |
The Case Study: API Mom, UberMesh, Scram Jet, Gallery
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
-
Scram Jet calls API Mom — Service binding configured without
"entrypoint". Call toenv.APIMOM.fetch()fails with “Callback returned incorrect type; expected ‘Promise’”. -
Gallery calls UberMesh — Service binding configured identically (no
"entrypoint"). Call toenv.UBERMESH.fetch()works perfectly. -
Difference identified — API Mom exports
CapabilityRegistryDOandRunnerDOat module level. UberMesh does not export any DO classes from its main module. -
Root cause confirmed — Removing the DO class exports from API Mom’s index.ts fixes the service binding. Adding them back breaks it.
-
Workaround found — Adding
"entrypoint": "default"to the service binding configuration in Scram Jet’s wrangler.jsonc fixes the issue without changing API Mom. -
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:
| Worker | Binding | Target | Has entrypoint: default? | Status |
|---|---|---|---|---|
| Scram Jet | APIMOM | api-mom | Yes | Fixed |
| Scram Jet | MEDIA_STORE | media-store | Yes | Fixed |
| Book Telic | API_MOM | api-mom | No | Needs fix |
| Book Telic | UBERMESH | ubermesh | No (not needed) | Works |
| UberMesh | APIMOM | api-mom | No | Needs fix |
| Gallery | UBERMESH | ubermesh | No (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.
Phase 1: “It Works in Gallery”
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
- Service Bindings — Runtime APIs — Overview of HTTP-style service bindings, configuration, and basic usage
- Service Bindings — RPC (WorkerEntrypoint) — RPC-based service bindings, named entrypoints, type generation
- Remote-Procedure Call (RPC) — Full RPC documentation: compatibility flags, supported types, reserved methods
- RPC — Reserved Methods — Methods like
.fetch()that have special behavior on RPC bindings - Bindings (env) — Complete list of all binding types available in Workers
- Configuration — Wrangler — Wrangler.jsonc configuration reference including service binding syntax
- Compatibility Flags — All compatibility flags including
global_fetch_strictly_publicandrpc - Fetch — Runtime API — Global fetch behavior, subrequest limits, and routing rules
- Durable Objects — Getting Started — DO class exports, migration configuration, binding setup
- Errors and Exceptions — Error codes including 1042 (Worker tried to fetch from another Worker)
- Workers Best Practices — Official recommendations for Worker architecture
Cloudflare Blog Posts
- We’ve added JavaScript-native RPC to Cloudflare Workers — Announcement blog post explaining the RPC system design, Cap’n Proto foundation, and WorkerEntrypoint class
- Durable Objects in Dynamic Workers — How DO exports work in the module system
Community Reports and Issues
- Worker error: Callback returned incorrect type; expected ‘Promise’ — Community report of the same error we encountered
- Error code 1042 when fetching within Worker — Community discussion of the *.workers.dev cross-fetch restriction
- NextJS Worker cannot call another Worker — Related report of cross-Worker communication failure
- How to Fix Cloudflare Workers Error 1042 — Third-party writeup of error 1042 causes and solutions
GitHub Issues (workers-sdk)
- Improve error message when trying to use Durable Objects with service-worker format Workers (#4943) — Related issue about DO export error messages
- DurableObject environment does not get Worker’s service binding (#2300) — Service binding propagation to DO environments
- wrangler types for service bindings missing RPC methods (#8902) — Type generation gaps for RPC service bindings
- ambient namespace for Env breaks types for subworkers with service bindings (#10020) — Type system issues with service bindings
- vite plugin regression: simple DO/WEP exports throw errors on startup (#11695) — Recent regression where DO + WorkerEntrypoint exports cause startup failures
Internal References (Atlas Ecosystem)
api-mom/src/index.ts— The Worker with DO exports that triggers the bugapi-mom/wrangler.jsonc— DO binding configuration for CapabilityRegistryDO and RunnerDOscram-jet/packages/engine/wrangler.jsonc— The fix:"entrypoint": "default"on service bindingsubermesh/wrangler.jsonc— Clean Worker (no DO exports) that works as a service binding targetubermesh/apps/gallery/wrangler.jsonc— Service binding to UberMesh (works without"entrypoint")book-telic/wrangler.jsonc— Service binding to API Mom WITHOUT"entrypoint"(needs fix)
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.