Skip to content

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.

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),
};
});
}),
) {}

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)

src/Counter.ts
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";
// init
const counters = yield* Counter;
return {
fetch: Effect.gen(function* () {
const counter = counters.getByName("user-123");
return HttpServerResponse.text(String(yield* counter.get()));
}),
};

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 Counter
import { Counter, CounterLive } from "./object.ts";
// ^^^^^^^ declared as part of WorkerA's public contract
export 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 WorkerA
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* () {
// ^^^^^^^^^^^^ 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.

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 WorkerA
export 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)),
);

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; }),
};

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 handler
const 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" },
});

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);

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 Effect
yield* state.storage.put("counter", 42);
const value = yield* state.storage.get("counter");

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;
}),
};

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);
}
}),
};
});
});

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 handler
yield* 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...
}
}),
};

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

// init
const 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

// init
const rooms = yield* Room;
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const room = rooms.getByName(roomId);
return yield* room.fetch(request);
}),
};

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

alchemy.run.ts
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

src/worker.ts
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;
}
}

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,
}),
},
});

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",
}),
},
});