DurableObject
Source:
src/Cloudflare/Workers/DurableObject.ts
A Cloudflare Durable Object namespace that manages globally unique, stateful instances with WebSocket hibernation support.
A Durable Object uses a two-phase pattern with two nested Effect.gen
blocks. The outer Effect resolves shared dependencies (other DOs,
containers, etc.) and the instance state reference
(Cloudflare.DurableObjectState). The inner Effect returns the
object’s public methods and WebSocket handlers — and is the only
place the state’s RuntimeContext-colored methods (storage.get,
storage.put, …) can actually run.
Effect.gen(function* () { // Phase 1: resolve shared dependencies + the instance state ref const db = yield* Cloudflare.D1.QueryDatabase(MyDatabase); const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () { // Phase 2: per-instance setup and public API. `state`'s methods // (storage.get/put, …) are RuntimeContext-colored, so the actual // I/O happens here, in the runtime closure. return { save: (data: string) => db.exec("INSERT ..."), fetch: Effect.gen(function* () { ... }), webSocketMessage: Effect.fn(function* (ws, msg) { ... }), }; });})There are two ways to define a Durable Object. See the {@link https://alchemy.run/concepts/platform | Platform concept} page for the full explanation.
- Inline — Effect implementation passed directly, single file.
- Modular — class and implementation in separate files for tree-shaking.
Inline Durable Objects
Section titled “Inline Durable Objects”Pass the Effect implementation as the second argument. This is the simplest approach — everything lives in one file. Convenient when the DO doesn’t need to be referenced by other Workers or DOs that would pull in its runtime dependencies.
export default class Counter extends Cloudflare.DurableObject<Counter>()( "Counter", Effect.gen(function* () { // init: bind resources + resolve the instance state ref const db = yield* Cloudflare.D1.QueryDatabase(MyDatabase); const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () { // runtime: state's storage methods are RuntimeContext-colored, // so the reads/writes live here const count = (yield* state.storage.get<number>("count")) ?? 0;
return { increment: () => Effect.gen(function* () { const next = count + 1; yield* state.storage.put("count", next); return next; }), get: () => Effect.succeed(count), }; }); }),) {}Modular Durable Objects
Section titled “Modular Durable Objects”When a Worker and a DO reference each other, or multiple Workers
bind the same DO, define the class separately from its .make()
call. The class is a lightweight identifier; .make() provides
the runtime implementation as an export default. Rolldown treats
.make() as pure, so the bundler tree-shakes it and all its
runtime dependencies out of any consumer’s bundle.
The class and .make() can live in the same file. This is the
same pattern used by Worker and Container.
Modular Durable Object (class + .make() in one file)
export class Counter extends Cloudflare.DurableObject<Counter>()( "Counter",) {}
export default Counter.make( Effect.gen(function* () { // init: bind resources + resolve the instance state ref const db = yield* Cloudflare.D1.QueryDatabase(MyDatabase); const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () { // runtime: state's storage methods are RuntimeContext-colored, // so the reads/writes live here const count = (yield* state.storage.get<number>("count")) ?? 0;
return { increment: () => Effect.gen(function* () { const next = count + 1; yield* state.storage.put("count", next); yield* db.prepare("INSERT INTO logs (count) VALUES (?)").bind(next).run(); return next; }), get: () => Effect.succeed(count), }; }); }),);Binding a modular DO from a Worker
// imports Counter; bundler tree-shakes .make()import Counter from "./Counter.ts";
// initconst counters = yield* Counter;
return { fetch: Effect.gen(function* () { const counter = counters.getByName("user-123"); return HttpServerResponse.text(String(yield* counter.get())); }),};Cross-Worker Binding
Section titled “Cross-Worker Binding”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 stub.
To make this type-safe, the host Worker must declare the DO
as part of its public contract via the third type argument to
Cloudflare.Worker<Self, Bindings, Deps>(). Deps is the set
of DO classes (or other Workers) the script exposes for other
scripts to bind to.
Host Worker declares the DO in its contract
// workerA.ts — hosts Counterimport { Counter, CounterLive } from "./object.ts";
// ^^^^^^^ declared as part of WorkerA's public contractexport class WorkerA extends Cloudflare.Worker<WorkerA, {}, Counter>()( "WorkerA", { main: import.meta.url },) {}
// WorkerA's Layer also provides the DO's Live implementation.export default WorkerA.make( Effect.gen(function* () { const counter = yield* Counter; return { fetch: Effect.gen(function* () { ... }) }; }).pipe(Effect.provide(CounterLive)),);Consumer Worker binds the DO via Counter.from(WorkerA)
// workerB.ts — binds to the same Counter, hosted by WorkerAimport { 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* () { // ^^^^^^^^^^^^ scriptName-bound stub of WorkerA's Counter const counter = yield* Counter.from(WorkerA); return { fetch: Effect.gen(function* () { const value = yield* counter.getByName("shared").get(); return HttpServerResponse.text(String(value)); }), }; }),) {}Only the host Worker’s Stack provides CounterLive — the
consumer Worker just imports the Counter class as a typed
identifier. Rolldown tree-shakes CounterLive (and its
dependencies) out of WorkerB’s bundle.
Using .from(Self) Inside the Host
Section titled “Using .from(Self) Inside the Host”Inside the host Worker, yield* Counter and
yield* Counter.from(Self) resolve to the same local namespace.
The .from(Self) form is preferred — especially in code that
may be extracted into a reusable Layer — because it makes the
scriptName explicit and lets the same Layer shape work whether
the consumer is the host or another script.
Counter.from(WorkerA) inside WorkerA itself
// workerA.ts — host uses `.from(Self)` instead of bare `yield* Counter`export default WorkerA.make( Effect.gen(function* () { const counter = yield* Counter.from(WorkerA); // same as `yield* Counter` return { fetch: Effect.gen(function* () { ... }) }; }).pipe(Effect.provide(CounterLive)),);A Worker can also host its own isolated namespace this way.
If a second host Worker declares Counter in its contract and
provides CounterLive, the DO instances under that script are
separate from the original host’s — same class, two namespaces.
Two hosts, two isolated namespaces
// workerC.ts — another host of Counter, isolated from WorkerAexport class WorkerC extends Cloudflare.Worker<WorkerC, {}, Counter>()( "WorkerC", { main: import.meta.url },) {}
export default WorkerC.make( Effect.gen(function* () { // .from(WorkerC) binds to WorkerC's own Counter namespace — // writes here are NOT visible from WorkerA's Counter. const counter = yield* Counter.from(WorkerC); return { fetch: Effect.gen(function* () { ... }) }; }).pipe(Effect.provide(CounterLive)),);RPC Methods
Section titled “RPC Methods”Any function you return from the inner Effect becomes an RPC method
that Workers can call through a stub. Methods must return an Effect.
The caller gets a fully typed stub — if your DO returns increment
and get, the stub exposes counter.increment() and counter.get().
return { increment: () => Effect.succeed(++count), get: () => Effect.succeed(count), reset: () => Effect.sync(() => { count = 0; }),};Returning Streams from RPC
Section titled “Returning Streams from RPC”RPC methods can return an Effect Stream and the caller will see
the chunks as they’re produced. Combine with Stream.schedule to
pace emission, or with Stream.fromQueue to bridge an inbound
subscription.
Streaming sequential numbers
import * as Schedule from "effect/Schedule";import * as Stream from "effect/Stream";
return { tick: (n: number) => Stream.iterate(0, (i) => i + 1).pipe( Stream.take(n), Stream.schedule(Schedule.spaced("100 millis")), ),};Forwarding the stream as a chunked HTTP response
// in a Worker fetch handlerconst counter = counters.getByName("tick");const stream = counter.tick(5).pipe( Stream.map((i) => `${i}\n`), Stream.encodeText,);return HttpServerResponse.stream(stream, { headers: { "content-type": "text/plain" },});Worker → DO HTTP forwarding
Section titled “Worker → DO HTTP forwarding”In addition to RPC methods, the typed stub exposes a fetch
method that forwards an HttpServerRequest straight to the DO.
The DO’s own fetch Effect produces the response — useful for
WebSocket upgrades and other request-shaped interactions.
const room = rooms.getByName(roomId);return yield* room.fetch(request);Accessing Instance State
Section titled “Accessing Instance State”Each Durable Object instance has its own transactional key-value
storage via Cloudflare.DurableObjectState. Resolve the state
reference in the outer (init) Effect, but call its methods —
storage.get, storage.put, … — only from the inner (runtime)
Effect: those methods are RuntimeContext-colored, so the type
system only allows them inside the runtime closure.
// inner (runtime) Effect — `state` was resolved in the outer Effectyield* state.storage.put("counter", 42);const value = yield* state.storage.get("counter");Background Work & Scopes
Section titled “Background Work & Scopes”Every RPC call and fetch into a Durable Object gets its own Effect
Scope. When the method finishes, the bridge closes that scope and
registers the close promise with workerd’s state.waitUntil — so
finalizers added with Effect.addFinalizer inside a method run after
the result is returned to the caller, without blocking it, and the
object stays alive until they settle.
For ad-hoc background work, state.waitUntil(effect) forks an Effect
with the caller’s full context and keeps the object alive until it
settles.
Attach cleanup to method scopes, not the constructor (init) closure —
the constructor runs once per in-memory instance under
blockConcurrencyWhile, and its scope is not tied to any call.
return { record: Effect.fn(function* (entry: string) { // runs after `record` returns; the DO stays alive until it settles yield* Effect.addFinalizer(() => state.storage.put(`audit:${entry}`, Date.now()).pipe(Effect.ignore), ); return "accepted" as const; }),
refresh: Effect.fn(function* () { // same idea, explicit form yield* state.waitUntil(recomputeExpensiveView()); return "scheduled" as const; }),};WebSocket Hibernation
Section titled “WebSocket Hibernation”Durable Objects support WebSocket hibernation — the runtime can
evict the object from memory while keeping connections open. Use
Cloudflare.upgrade() to accept a connection, and return
webSocketMessage / webSocketClose handlers to process events
when the object wakes back up.
Accepting a WebSocket connection
return { fetch: Effect.gen(function* () { const [response, socket] = yield* Cloudflare.upgrade(); socket.serializeAttachment({ id: crypto.randomUUID() }); return response; }),};Handling messages and close events
return { webSocketMessage: Effect.fn(function* ( socket: Cloudflare.WebSocket, message: string | Uint8Array, ) { const text = typeof message === "string" ? message : new TextDecoder().decode(message); // process the message }), webSocketClose: Effect.fn(function* ( ws: Cloudflare.WebSocket, code: number, reason: string, ) { yield* ws.close(code, reason); }),};Recovering sessions after hibernation
Resolve the state reference in the outer Effect, but place the
rehydration loop (state.getWebSockets() is RuntimeContext-colored)
inside the inner Effect.gen so it runs every time the DO
instance is reconstructed (including after Cloudflare wakes the DO
from hibernation).
Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () { const sessions = new Map<string, Cloudflare.WebSocket>();
// Rehydrate the in-memory session map after hibernation. for (const socket of yield* state.getWebSockets()) { const data = socket.deserializeAttachment<{ id: string }>(); if (data) sessions.set(data.id, socket); }
return { fetch: Effect.gen(function* () { const [response, socket] = yield* Cloudflare.upgrade(); const id = crypto.randomUUID(); socket.serializeAttachment({ id }); sessions.set(id, socket); return response; }), webSocketMessage: Effect.fn(function* (socket, message) { const text = typeof message === "string" ? message : new TextDecoder().decode(message); for (const peer of sessions.values()) { yield* peer.send(text); } }), }; });});Scheduled Alarms
Section titled “Scheduled Alarms”Each Durable Object can have a single alarm timestamp. Alchemy
layers a small SQLite-backed scheduler on top via
Cloudflare.Workers.scheduleEvent and Cloudflare.Workers.processScheduledEvents,
so you can register many named events with arbitrary payloads and
fire them from a single alarm handler.
// schedule from a request or message handleryield* Cloudflare.Workers.scheduleEvent( "reminder-1", new Date(Date.now() + 60_000), { message: "your meeting starts in a minute" },);
return { alarm: () => Effect.gen(function* () { const fired = yield* Cloudflare.Workers.processScheduledEvents; for (const event of fired) { const payload = event.payload as { message: string }; // dispatch / broadcast / persist... } }),};Using from a Worker
Section titled “Using from a Worker”Yield the DO class in your Worker’s init phase to get a namespace
handle. Call getByName or getById to get a typed stub, then
call any RPC method or forward an HTTP request with fetch.
Calling RPC methods
// initconst counters = yield* Counter;
return { fetch: Effect.gen(function* () { const counter = counters.getByName("user-123"); yield* counter.increment(); const value = yield* counter.get(); return HttpServerResponse.text(String(value)); }),};Forwarding an HTTP request
// initconst rooms = yield* Room;
return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const room = rooms.getByName(roomId); return yield* room.fetch(request); }),};Binding in an Async Worker
Section titled “Binding in an Async Worker”When using an Async Worker (plain async fetch handler, no Effect
runtime), declare Durable Objects in the bindings prop of the
Worker resource. Pass a DurableObject reference with a
className matching the exported DurableObject subclass in your
worker source file. If className is omitted, it defaults to the
namespace name. Use Cloudflare.InferEnv to get a fully typed
env object that includes the namespace.
Declaring a DO binding in the stack
import type { Counter } from "./src/worker.ts";
export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
export const Worker = Cloudflare.Worker("Worker", { main: "./src/worker.ts", bindings: { Counter: Cloudflare.DurableObject<Counter>("Counter"), },});Using the DO from a plain async handler
import { DurableObject } from "cloudflare:workers";import type { WorkerEnv } from "../alchemy.run.ts";
export default { async fetch(request: Request, env: WorkerEnv) { const counter = env.Counter.getByName("my-counter"); const count = await counter.increment(); return new Response(JSON.stringify({ count })); },};
export class Counter extends DurableObject { private counter = 0; async increment() { return ++this.counter; }}Cross-Script Binding in an Async Worker
Section titled “Cross-Script Binding in an Async Worker”Async Workers can also bind to a Durable Object hosted by another
Worker script. The host Worker declares and exports the DO class. The
consumer Worker declares a DurableObject with scriptName
set to the host Worker’s script name.
Cross-script async bindings are references only: the consumer uploads the binding metadata, but Alchemy does not drive class migrations for the foreign class. Deploy the host first so Cloudflare can verify that the target script exports the requested class.
Host Worker owns the Durable Object class
const host = yield* Cloudflare.Worker("Host", { main: "./src/host.ts", bindings: { Counter: Cloudflare.DurableObject<Counter>("Counter"), },});Consumer Worker binds to the host script
const consumer = yield* Cloudflare.Worker("Consumer", { main: "./src/consumer.ts", bindings: { Counter: Cloudflare.DurableObject<Counter>("Counter", { scriptName: host.workerName, }), },});Binding to a different exported class name
const consumer = yield* Cloudflare.Worker("Consumer", { main: "./src/consumer.ts", bindings: { Counter: Cloudflare.DurableObject<Counter>("Counter", { className: "CounterV2", scriptName: host.workerName, }), },});Adopting an Existing Durable Object
Section titled “Adopting an Existing Durable Object”When you adopt a Worker that already exists on Cloudflare — created
outside Alchemy via Wrangler, the dashboard, or the raw API — its
Durable Object classes are adopted along with it. You opt in to the
takeover the same way you adopt any foreign resource: with
adopt(true) (or the --adopt CLI flag), since Worker.read reports
a worker with no Alchemy ownership tags as Unowned.
Alchemy normally tracks which class backs each binding through an
alchemy:do:<logicalId>:<className> tag it writes on the script. A
foreign worker has no such tag, so on the adopting deploy Alchemy
falls back to matching your binding to the live class by binding
name. The class is then reused in place — not recreated — so
Cloudflare’s migration engine doesn’t reject the upload for creating a
class that already exists.
The consequence is a one-time constraint: on the adopting deploy the
binding’s className must match the class that already exists on the
worker. You cannot rename the class in the same deploy that adopts
it. Once the deploy completes, Alchemy owns the worker and has written
the alchemy:do: tag, so subsequent renames are driven by logical id
and work normally.
Adopting a worker whose Counter class already exists
// The worker + `Counter` class were created outside Alchemy.// `className` must match the existing class on this first deploy.const worker = yield* Cloudflare.Worker("Worker", { name: "existing-worker", main: "./src/worker.ts", bindings: { Counter: Cloudflare.DurableObject<Counter>("Counter"), },}).pipe(adopt(true));Renaming the class — only after adoption
// A SECOND deploy, after the one above. Alchemy now owns the worker// and maps the binding by logical id, so the class can be renamed.const worker = yield* Cloudflare.Worker("Worker", { name: "existing-worker", main: "./src/worker.ts", bindings: { Counter: Cloudflare.DurableObject<Counter>("Counter", { className: "CounterV2", }), },});