Skip to content

Bind to another Worker's Durable Object

A Durable Object is hosted by exactly one Worker, but any number of other Workers can bind to the same DO. This is how you share state across Workers: one Worker hosts the DO, every other Worker addresses it by scriptName and gets a typed RPC stub.

By the end of this part you’ll have two Workers, WorkerA and WorkerB, sharing a single Counter Durable Object. Writes through either Worker are visible from the other.

The DO class lives in a separate file so both Workers can import it as a typed identifier without pulling in the runtime implementation.

src/object.ts
import type { RuntimeContext } from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export class Counter extends Cloudflare.DurableObject<
Counter,
{
increment: () => Effect.Effect<number, never, RuntimeContext>;
get: () => Effect.Effect<number, never, RuntimeContext>;
}
>()("Counter") {}

The class is purely a tagged identifier. Both Workers can import it without dragging in the runtime — the bundler tree-shakes .make() (which we’ll add next) out of any consumer that doesn’t actually host the DO.

Counter.make(...) provides the per-instance Effect that runs inside the DO. Export it as default so it can be tree-shaken when imported by name.

src/object.ts
export class Counter extends Cloudflare.DurableObject<
Counter,
{
increment: () => Effect.Effect<number, never, RuntimeContext>;
get: () => Effect.Effect<number, never, RuntimeContext>;
}
>()("Counter") {}
export default Counter.make(
Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
let count = (yield* state.storage.get<number>("count")) ?? 0;
return {
increment: () =>
Effect.gen(function* () {
count += 1;
yield* state.storage.put("count", count);
return count;
}),
get: () => Effect.succeed(count),
};
});
}),
);

Only Workers that import this file as import CounterLive from "./object.ts" will pull in the runtime. Workers that only import the class (import { Counter } from "./object.ts") get just the type.

WorkerA hosts Counter — its bundle contains the DO runtime and Cloudflare runs the DO class inside WorkerA’s script.

The key move is the third type argument on Cloudflare.Worker:

src/workerA.ts
import * as Cloudflare from "alchemy/Cloudflare";
import { Counter } from "./object.ts";
export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()(
"WorkerA",
) {}

Worker<Self, Bindings, Deps> — the third slot Deps is what the script publishes as part of its public contract. By writing , Counter, we’re saying “WorkerA hosts Counter; other scripts may bind to it.”

WorkerA.make(...) is where the runtime lives. Provide CounterLive (the default export from object.ts) so that WorkerA’s bundle actually contains the DO class:

src/workerA.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/http/HttpServerRequest";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
import { Counter } from "./object.ts";
import CounterLive, { Counter } from "./object.ts";
export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()(
"WorkerA",
) {}
export default WorkerA.make(
{ main: import.meta.url },
Effect.gen(function* () {
const counters = yield* Counter;
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const name = new URL(request.url, "http://x").pathname.slice(1);
const next = yield* counters.getByName(name).increment();
return HttpServerResponse.json({ value: next });
}),
};
}).pipe(Effect.provide(CounterLive)),
);

yield* Counter inside WorkerA’s constructor binds the DO locally — WorkerA’s env.Counter is wired up at deploy time and the class ships in the bundle.

WorkerB does not host the DO. It imports WorkerA purely as a type and uses Counter.from(WorkerA) to declare a binding to the existing namespace running under WorkerA’s script:

src/workerB.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/http/HttpServerRequest";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
import { Counter } from "./object.ts";
import { WorkerA } from "./workerA.ts";
export default class WorkerB extends Cloudflare.Worker<WorkerB>()(
"WorkerB",
{ main: import.meta.url },
Effect.gen(function* () {
const counters = yield* Counter.from(WorkerA);
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const name = new URL(request.url, "http://x").pathname.slice(1);
const value = yield* counters.getByName(name).get();
return HttpServerResponse.json({ value });
}),
};
}),
) {}

Counter.from(WorkerA) reads WorkerA’s Deps to confirm that Counter is part of its public contract, then produces a typed namespace stub. Under the hood it emits a Cloudflare binding with scriptName: "WorkerA" so the runtime routes every call to WorkerA’s hosted instance.

The Stack composes both Workers. WorkerA’s Layer is provided so its Counter runtime is hooked up; WorkerB consumes it via binding only:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import WorkerALive, { WorkerA } from "./src/workerA.ts";
import WorkerB from "./src/workerB.ts";
export default Alchemy.Stack(
"CrossWorkerDO",
{ state: Cloudflare.state(), providers: Cloudflare.providers() },
Effect.gen(function* () {
const a = yield* WorkerA;
const b = yield* WorkerB;
return {
urlA: a.url.as<string>(),
urlB: b.url.as<string>(),
};
}).pipe(Effect.provide(WorkerALive)),
);

Effect.provide(WorkerALive) is the only place the DO runtime enters the program. Without it, yield* WorkerA would fail to resolve.

Hit urlA/foo to increment, then urlB/foo to read — both Workers route to the same DO instance by name:

test/integ.test.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Test from "alchemy/Test/Vitest";
import { expect } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as HttpClient from "effect/http/HttpClient";
import Stack from "../alchemy.run.ts";
const { test, beforeAll, deploy } = Test.make({
providers: Cloudflare.providers(),
});
const stack = beforeAll(deploy(Stack));
test(
"WorkerB reads counter written by WorkerA",
Effect.gen(function* () {
const { urlA, urlB } = yield* stack;
const client = yield* HttpClient.HttpClient;
yield* client.post(`${urlA}/foo`);
yield* client.post(`${urlA}/foo`);
const res = yield* client.get(`${urlB}/foo`);
expect((yield* res.json) as { value: number }).toEqual({ value: 2 });
}),
);

Inside WorkerA.make we wrote yield* Counter to bind the locally-hosted DO. That works, but it’s not the form you want when the code might be extracted into a reusable Layer or shared between scripts.

Counter.from(WorkerA) also works inside WorkerA itself — calling .from(Self) on the host resolves to the same local namespace as yield* Counter:

src/workerA.ts
export default WorkerA.make(
{ main: import.meta.url },
Effect.gen(function* () {
const counters = yield* Counter;
const counters = yield* Counter.from(WorkerA);
return {
fetch: Effect.gen(function* () { ... }),
};
}).pipe(Effect.provide(CounterLive)),
);

This matters most when you extract fetch logic into a Layer (or any service) that wants a Counter namespace. With Counter.from(Self), the service is explicit about which script’s Counter it talks to. A Worker that hosts its own isolated namespace uses .from(Self); a consumer Worker uses .from(Host). The shape is identical, which keeps the Layer host-agnostic.

Hosting isn’t forever — suppose Counter should now live under WorkerB instead of WorkerA. A Durable Object class can move between Workers with its data intact: Cloudflare transfers the namespace (same instances, same storage) onto the new host’s script. Moves are always declared — a class that disappears from one Worker and appears on another is otherwise ambiguous between “transfer the data” and “delete it, start fresh” — so the class states where it came from.

Add transferredFrom to the class declaration, naming the former host:

src/object.ts
export class Counter extends Cloudflare.DurableObject<
Counter,
{
increment: () => Effect.Effect<number, never, RuntimeContext>;
get: () => Effect.Effect<number, never, RuntimeContext>;
}
>()("Counter") {}
>()("Counter", { transferredFrom: "WorkerA" }) {}

"WorkerA" is the former host’s Worker logical id, resolved to its physical script through alchemy’s ownership tags in the same stack + stage (for a cross-stack move, use the physical script name instead). Whichever Worker hosts Counter next will transfer the namespace from it.

WorkerB takes over the host role: it declares Counter in its Deps, provides CounterLive, and binds with .from(Self):

src/workerB.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/http/HttpServerRequest";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
import CounterLive, { Counter } from "./object.ts";
export class WorkerB extends Cloudflare.Worker<WorkerB, {}, Counter>()(
"WorkerB",
) {}
export default WorkerB.make(
{ main: import.meta.url },
Effect.gen(function* () {
const counters = yield* Counter.from(WorkerB);
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const name = new URL(request.url, "http://x").pathname.slice(1);
const value = yield* counters.getByName(name).get();
return HttpServerResponse.json({ value });
}),
};
}).pipe(Effect.provide(CounterLive)),
);

WorkerA stops shipping the runtime and binds the same namespace cross-script, now hosted by WorkerB:

src/workerA.ts
import CounterLive, { Counter } from "./object.ts";
import { Counter } from "./object.ts";
import { WorkerB } from "./workerB.ts";
export class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()(
export class WorkerA extends Cloudflare.Worker<WorkerA>()(
"WorkerA",
) {}
export default WorkerA.make(
{ main: import.meta.url },
Effect.gen(function* () {
const counters = yield* Counter.from(WorkerA);
const counters = yield* Counter.from(WorkerB);
return {
fetch: Effect.gen(function* () { ... }),
};
}).pipe(Effect.provide(CounterLive)),
}),
);

The Stack now provides both Layers — WorkerBLive carries the DO runtime, WorkerALive no longer does:

alchemy.run.ts
import WorkerALive, { WorkerA } from "./src/workerA.ts";
import WorkerB from "./src/workerB.ts";
import WorkerBLive, { WorkerB } from "./src/workerB.ts";
Effect.gen(function* () {
const a = yield* WorkerA;
const b = yield* WorkerB;
return {
urlA: a.url.as<string>(),
urlB: b.url.as<string>(),
};
}).pipe(Effect.provide(WorkerALive)),
}).pipe(Effect.provide([WorkerALive, WorkerBLive])),
Terminal window
bun alchemy deploy

WorkerA references WorkerB through Counter.from(WorkerB), so the engine deploys WorkerB first. Its deploy moves the namespace and every stored object to WorkerB’s script, and WorkerA’s deploy then converges on its own (nothing is deleted for a class that moved away). Every counter keeps its count. Afterwards the transferredFrom declaration is inert — on fresh stages and on every later deploy it does nothing — so leave it in place.

If the class later moves again — say WorkerB → WorkerC — append to the declaration rather than replacing it:

// src/object.ts — after a second move
>()("Counter", { transferredFrom: ["WorkerA", "WorkerB"] }) {}

The namespace transfers from whichever listed host currently holds it, so a stage that lagged behind (or skipped the intermediate release) still converges from wherever its namespace lives.

Two rules for less common shapes:

  • Pure moves — the former host drops the DO entirely, keeping no cross-script reference — take two deploys: first add the class to the new host with transferredFrom and deploy, then remove it from the former host and deploy.
  • Cross-stack moves name the former host by physical script name, and the new host’s stack deploys first.

Two Workers, one DO, type-safe end-to-end. The third type argument , Counter on WorkerA is the small but critical piece that makes Counter.from(WorkerA) type-check from WorkerB — and that same form (Counter.from(Self)) lets a host script bind to its own DO from inside a Layer without caring whether it’s the host or a consumer.