Skip to content

Durable Objects

A Durable Object (DO) is a globally-unique stateful instance addressed by name: every request for "user-123" — from any Worker, anywhere in the world — lands on the same instance, with its own transactional SQLite-backed storage. That combination of identity + storage + single-threaded execution makes DOs the right tool for per-entity state: counters, chat rooms, game sessions, WebSocket hubs, rate limiters, collaborative documents.

In alchemy a DO is a class with the same two-phase Effect pattern as a Worker, and every method you return becomes a typed RPC method — no schemas, no serialization boilerplate. This page builds one from scratch: a Counter that keeps a per-key count in transactional storage, exposes RPC methods, and streams a sequence of numbers back to the caller.

Like Workers, a DO has two Effect.gen blocks: the outer one resolves the instance’s state and any shared dependencies, and the inner one returns the public API.

Create src/counter.ts with the smallest possible DO — empty public API, no state yet:

src/counter.ts
import * as
import Cloudflare
Cloudflare
from "alchemy/Cloudflare";
import * as
import Effect
Effect
from "effect/Effect";
export default class
class Counter
Counter
extends
import Cloudflare
Cloudflare
.
const DurableObject: Cloudflare.DurableObjectClass
<Counter>() => <Shape, Req>(name: string, impl: Effect.Effect<Effect.Effect<Shape, never, Cloudflare.DurableObjectState | Scope | RuntimeContext>, never, Req>) => Effect.Effect<Cloudflare.DurableObject<Counter>, never, Cloudflare.Worker | Extract<Req, Cloudflare.Container<Id extends string = string>.Application<any>>> & (new (_: never) => Shape) (+3 overloads)
DurableObject
<
class Counter
Counter
>()(
"Counter",
import Effect
Effect
.
const gen: <never, Effect.Effect<{}, never, never>>(f: () => Generator<never, Effect.Effect<{}, never, never>, never>) => Effect.Effect<Effect.Effect<{}, never, never>, never, never> (+1 overload)

Provides a way to write effectful code using generator functions, simplifying control flow and error handling.

When to use

Use when you want to write effectful code that looks and behaves like synchronous code, while still handling asynchronous tasks, errors, and complex control flow such as loops and conditions.

Generator functions work similarly to async/await but keep errors, requirements, and interruption in the Effect type. You can yield* values from effects and return the final result at the end.

Example (Sequencing effects with generators)

import { Data, Effect } from "effect"
class DiscountRateError extends Data.TaggedError("DiscountRateError")<{}> {}
const addServiceCharge = (amount: number) => amount + 1
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, DiscountRateError> =>
discountRate === 0
? Effect.fail(new DiscountRateError())
: Effect.succeed(total - (total * discountRate) / 100)
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))
export const program = Effect.gen(function*() {
const transactionAmount = yield* fetchTransactionAmount
const discountRate = yield* fetchDiscountRate
const discountedAmount = yield* applyDiscount(
transactionAmount,
discountRate
)
const finalAmount = addServiceCharge(discountedAmount)
return `Final amount to charge: ${finalAmount}`
})

@categoryconstructors

@since2.0.0

gen
(function* () {
// Outer (init): resolve the instance state and shared
// dependencies here.
return
import Effect
Effect
.
const gen: <never, {}>(f: () => Generator<never, {}, never>) => Effect.Effect<{}, never, never> (+1 overload)

Provides a way to write effectful code using generator functions, simplifying control flow and error handling.

When to use

Use when you want to write effectful code that looks and behaves like synchronous code, while still handling asynchronous tasks, errors, and complex control flow such as loops and conditions.

Generator functions work similarly to async/await but keep errors, requirements, and interruption in the Effect type. You can yield* values from effects and return the final result at the end.

Example (Sequencing effects with generators)

import { Data, Effect } from "effect"
class DiscountRateError extends Data.TaggedError("DiscountRateError")<{}> {}
const addServiceCharge = (amount: number) => amount + 1
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, DiscountRateError> =>
discountRate === 0
? Effect.fail(new DiscountRateError())
: Effect.succeed(total - (total * discountRate) / 100)
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))
export const program = Effect.gen(function*() {
const transactionAmount = yield* fetchTransactionAmount
const discountRate = yield* fetchDiscountRate
const discountedAmount = yield* applyDiscount(
transactionAmount,
discountRate
)
const finalAmount = addServiceCharge(discountedAmount)
return `Final amount to charge: ${finalAmount}`
})

@categoryconstructors

@since2.0.0

gen
(function* () {
// Inner: return the public API for this instance.
return {};
});
}),
) {}

Each DO instance has its own key/value storage, backed by SQLite. Resolve Cloudflare.DurableObjectState in the outer Effect, then pull the current count out of storage in the inner init so it survives restarts and hibernation:

Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
let count = (yield* state.storage.get<number>("count")) ?? 0;
return {};
});
});

Cloudflare.DurableObjectState is the same per-instance handle Cloudflare exposes for storage, setAlarm, acceptWebSocket, and friends. We’ll use it more in the next part for WebSockets.

Any function you return from the inner Effect that produces an Effect becomes a typed RPC method. Add one to mutate the count and one to read it:

Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
let count = (yield* state.storage.get<number>("count")) ?? 0;
return {};
return {
increment: () =>
Effect.gen(function* () {
count += 1;
yield* state.storage.put("count", count);
return count;
}),
get: () => Effect.succeed(count),
};
});
});

Workers will see counter.increment() returning Effect<number> and counter.get() returning Effect<number>, fully type-checked through the Cloudflare RPC machinery.

Yield the Counter class in your Worker’s init phase to get a namespace handle:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import Counter from "./counter.ts";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const counters = yield* Counter;
return {
fetch: Effect.gen(function* () {
return HttpServerResponse.text("Hello from my Worker!");
}),
};
}),
);

yield* Counter in init registers the DO with the Worker (binding + class-migration metadata) and hands you the namespace.

Inside the fetch handler, route POST /counter/:name to the DO by calling getByName(...) to obtain a typed stub, then invoking increment():

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import Counter from "./counter.ts";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const counters = yield* Counter;
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
if (request.url.startsWith("/counter/") && request.method === "POST") {
const name = request.url.split("/").pop()!;
const next = yield* counters.getByName(name).increment();
return HttpServerResponse.text(String(next));
}
return HttpServerResponse.text("Hello from my Worker!");
}),
};
}),
);

counters.getByName(name) returns a typed stub: the Worker sees increment(): Effect<number> and get(): Effect<number> exactly as you defined them. Calling .increment() round-trips through Cloudflare’s RPC machinery to the durable instance.

Re-deploy. Alchemy plans a Worker update and a new Counter namespace:

Terminal window
bun alchemy deploy

Add a test that hits /counter/foo twice (expecting 1 then 2) and /counter/bar once (expecting 1) — each name addresses its own stateful instance:

test/integ.test.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Test from "alchemy/Test/Bun";
import { expect } from "bun:test";
import * as Effect from "effect/Effect";
import * as HttpClient from "effect/unstable/http/HttpClient";
import Stack from "../alchemy.run.ts";
const { test, beforeAll, deploy } = Test.make({
providers: Cloudflare.providers(),
state: Cloudflare.state(),
});
const stack = beforeAll(deploy(Stack));
test(
"Counter persists per key",
Effect.gen(function* () {
const { url } = yield* stack;
const a1 = yield* HttpClient.post(`${url}/counter/foo`);
expect(yield* a1.text).toBe("1");
const a2 = yield* HttpClient.post(`${url}/counter/foo`);
expect(yield* a2.text).toBe("2");
const b1 = yield* HttpClient.post(`${url}/counter/bar`);
expect(yield* b1.text).toBe("1");
}),
);
Terminal window
bun test test/integ.test.ts

RPC isn’t limited to single values — any Effect (or Stream) you return becomes a typed RPC method. Let’s add a tick(n) method that emits a sequence of numbers 100ms apart, then expose an HTTP route that streams them back to the client.

Add tick to src/counter.ts:

src/counter.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import * as Stream from "effect/Stream";
export default class Counter extends Cloudflare.DurableObject<Counter>()(
"Counter",
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),
tick: (n: number) =>
Stream.iterate(0, (i) => i + 1).pipe(
Stream.take(n),
Stream.schedule(Schedule.spaced("100 millis")),
),
};
});
}),
) {}

Forward the stream from the Worker’s fetch handler. Use HttpServerResponse.stream to flush each chunk as it arrives:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import Counter from "./counter.ts";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const counters = yield* Counter;
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
if (request.url.startsWith("/counter/") && request.method === "POST") {
const name = request.url.split("/").pop()!;
const next = yield* counters.getByName(name).increment();
return HttpServerResponse.text(String(next));
}
if (request.url.startsWith("/tick/") && request.method === "GET") {
const n = Number(request.url.split("/").pop()!);
const stream = counters.getByName("tick").tick(n).pipe(
Stream.map((i) => `${i}\n`),
Stream.encodeText,
);
return HttpServerResponse.stream(stream, {
headers: { "content-type": "text/plain" },
});
}
return HttpServerResponse.text("Hello from my Worker!");
}),
};
}),
);

Add the test:

test/integ.test.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Test from "alchemy/Test/Bun";
import { expect } from "bun:test";
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import * as HttpClient from "effect/unstable/http/HttpClient";
import Stack from "../alchemy.run.ts";
const { test, beforeAll, deploy } = Test.make({
providers: Cloudflare.providers(),
state: Cloudflare.state(),
});
const stack = beforeAll(deploy(Stack));
test(
"tick streams 5 sequential values",
Effect.gen(function* () {
const { url } = yield* stack;
const response = yield* HttpClient.get(`${url}/tick/5`);
const lines = yield* response.stream.pipe(
Stream.decodeText,
Stream.splitLines,
Stream.runCollect,
);
expect([...lines]).toEqual(["0", "1", "2", "3", "4"]);
}),
);

The DO produces values lazily, the runtime ferries each chunk back to the Worker, and the Worker pipes them straight onto the HTTP response — all type-checked end-to-end.

Everything you just built is RPC — and you never declared a schema. Any function returning an Effect (or a Stream) from the inner block becomes a typed method, and getByName(name) hands callers a stub that mirrors those signatures exactly:

const counters = yield* Counter;
const counter = counters.getByName("user-123");
const value = yield* counter.increment(); // Effect<number>

This schemaless, typed-stub pattern — the DO shape of Schemaless RPC — is the recommended way to talk to a Durable Object. The types flow from the implementation — add a method, and every caller sees it immediately; change a return type, and every caller that mismatches fails to compile. The same mechanism works across Workers: one Worker can host the DO while others bind to it by class — see Bind to another Worker’s Durable Object.

Alchemy also ships a schema-based RpcDurableObject that serves an Effect RPC group on the DO’s fetch. It is no longer the suggested path for DO method calls — the schemaless bridge covers typed methods and streaming without the ceremony. Its one remaining niche is when return values are Schema.Class instances whose identity must survive the boundary — the schemaless bridge serializes return values and strips class identity — see Cloudflare.RpcDurableObject. If you want schema-validated procedures (payload validation, tagged error schemas shared with external clients), reach for Effect RPC on a Worker instead.

Guides that build on Durable Objects:

Related:

  • Workers — the runtime every DO is reached through.

Reference: