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. It also shows how to schedule durable callbacks atomically with application writes.

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 Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export default class Counter extends Cloudflare.DurableObject<Counter>()(
"Counter",
Effect.gen(function* () {
// Outer (Construction): resolve the instance state and shared
// dependencies here.
return Effect.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 constructor 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.

For objects that use SQL tables, generate and commit migration files with your schema changes before deploying. Load them in the outer Effect and apply them in the inner Effect:

Effect.gen(function* () {
const migrations = yield* Cloudflare.SqlMigrations("./drizzle");
return Effect.gen(function* () {
yield* migrations.apply().pipe(Effect.orDie);
return {};
});
});

Pending SQL runs when each object activates, before its public methods are available. See SQL migrations for the full setup, or pass the same migrations to Drizzle.DurableObject({ migrations, relations }).

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 Construction 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 the constructor 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.

A Durable Object is created wherever its first-ever request came from, and it lives there for good. That is the right default when an instance’s traffic all comes from whoever created it — getByName(userId) lands next to that user by construction.

It is the wrong default as soon as the name stops tracking geography. A pool sharded by hash (pool:7), a fixed roster, a shard index — any of these can be created by whichever request happened to arrive first, and every later caller pays for that accident. A Sydney user hashed onto an instance a Frankfurt request created is making a cross-planet round trip on every call, forever.

Pass a locationHint to say where a new instance should be created:

const shard = shards.getByName("pool:apac:3", { locationHint: "apac" });

Hints only apply at creation, so this is safe to add to an existing call site: instances that already exist stay where they are, and nothing you pass can move them.

Derive the name and the hint from the same region, so each region addresses its own shards:

// in a Worker fetch handler
const region = hintFor(request.cf?.continent); // your own mapping
const shard = shards.getByName(`pool:${region}:${index}`, {
locationHint: region,
});

Available hints are wnam, enam, sam, weur, eeur, apac, oc, afr, and me. Each is best-effort: Cloudflare places the instance in the nearest location it can to the hinted region, which may not be inside it. Under alchemy dev there is only one location to place anything in, so hints are accepted and ignored — placement is only observable once deployed.

A Durable Object shares its isolate’s layer build with the Worker that hosts it — the entrypoint’s constructor runs once per isolate, and every DO activation (including hibernatable-WebSocket wakes, which re-run the constructor) reuses it. Each method call, fetch, alarm, or WebSocket event then runs with a fresh Scope, closed via state.waitUntil after the call settles — so Effect.addFinalizer inside a method runs after the RPC response is returned, and a returned Stream keeps the scope alive until it finishes draining.

As everywhere on workerd, there is no isolate-teardown hook: cleanup belongs in methods (call scope), not in the class constructor or the Construction phase. Per-call resources like Drizzle.Postgres pools open lazily inside the method and close with its scope (see the SQL connection lifecycle). See Instance scope vs request scope for the model across all runtimes.

Use Alchemy.makeCallback when work must survive the request and run later. Durable Objects persist these jobs in SQLite and deliver them through native alarms; a timer or a forked fiber does not provide that persistence.

For this example, create a Document object that will save its current body and schedule a revision snapshot:

src/document.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export default class Document extends Cloudflare.DurableObject<Document>()(
"Document",
Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
return {};
});
}),
) {}

Each named Document has its own storage and pending jobs, just as each named Counter has its own count. Bind it to a Worker by yielding the Document class in the Worker’s Construction phase.

Register the handler in the inner, per-instance Effect, before returning the public methods:

src/document.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
// Inside Document's outer Effect:
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
const onSnapshot = yield* Alchemy.makeCallback(
"snapshot",
Effect.fn(function* (payload: { revision: string; body: string }) {
yield* state.storage.put(`revisions:${payload.revision}`, payload.body);
}),
);
return {};
});

onSnapshot is a typed scheduling handle, not an RPC method. Alchemy re-registers its handler whenever the instance is reconstructed. The public API is Alchemy.makeCallback; Cloudflare Durable Objects are its first supported host. Registration in a Worker, in the outer Construction Effect, or later inside an RPC method is unsupported.

Payloads must be JSON values: objects, arrays, strings, finite numbers, booleans, or null. Values such as Date, undefined, and BigInt are rejected when scheduling, even if the handler’s TypeScript type accepts them. Encode dates as strings or timestamps before scheduling.

Add a method that writes the document and schedules its snapshot in the same storage transaction:

return {};
return {
save: Effect.fn(function* (revision: string, body: string) {
yield* state.storage.transaction(
Effect.gen(function* () {
yield* state.storage.put("document", { revision, body });
yield* onSnapshot.schedule(revision, {
after: "30 seconds",
payload: { revision, body },
});
}),
);
}),
};

The application write, pending callback, and native alarm update commit or roll back together. This works for SQLite and key/value writes on the same Durable Object. The handler runs later; external HTTP calls, R2 writes, and other objects’ storage are outside this transaction.

The existing storage.transaction((txn) => effect) overload remains supported. Nested transactions on the same fiber join the active transaction rather than create a savepoint. Keep transactional operations on that fiber: sibling-fiber storage access is rejected, as are further writes after txn.rollback(). Storage rollback does not undo assignments to JavaScript variables.

Within the same instance, the callback name and job ID identify a pending job. Scheduling the same pair again replaces its payload and due time. Use at instead of after for an absolute time:

// Inside a Document method; revision and body are method arguments.
yield* onSnapshot.schedule(revision, {
at: new Date("2026-12-01T09:00:00Z"),
payload: { revision, body },
});

Pass exactly one of at or after. IDs are independent across callback names and across Durable Object instances. Replacing a pending job does not interrupt an invocation that is already running.

Cancel from existing application logic; no additional RPC method is needed:

yield* onSnapshot.cancel(revision);

Cancellation joins an active storage transaction, succeeds for absent IDs, and does not interrupt a running handler. Failures use Alchemy.CallbackError.

Delivery is at least once, not exactly once. A failed or interrupted handler leaves its job pending. The default recovery delay is 30 seconds; set a finite, positive delay when registering the handler:

const onSnapshot = yield* Alchemy.makeCallback(
"snapshot",
Effect.fn(function* (payload: { revision: string; body: string }) {
yield* state.storage.put(`revisions:${payload.revision}`, payload.body);
}),
{ retry: { delay: "10 seconds" } },
);

Alchemy persists a recovery wake before invoking the handler and acknowledges the job only after success. A crash between an external write and acknowledgement can repeat that write, so use idempotency keys for external operations. This example can repeat safely because it writes the same revision key and body. Each invocation receives a fresh Effect scope. Delivery times are earliest eligible times, not exact execution deadlines.

Existing scheduleEvent jobs stay in alchemy_scheduled_events, with their IDs, timestamps, repeat intervals, and JSON payloads intact. Alchemy atomically adds separate callback and schema-version tables to the previously unversioned database. It rejects an unsupported newer schema without modifying its data.

Keep the existing alarm handler, its Cloudflare.processScheduledEvents call, and its application-specific dispatch for pending legacy jobs. Those jobs are not converted into makeCallback registrations, even if their payload contains a field named callback. Repeating legacy jobs continue to repeat until explicitly cancelled.

Legacy events and callbacks share the object’s single native alarm; Alchemy coordinates the earliest pending timestamp across both queues. A callback-only object does not need an alarm handler. Avoid manually setting or deleting the native alarm while either scheduler owns pending work.

Callback names and payload formats are also persisted contracts. Keep old registrations while their jobs are pending, and keep new handlers compatible with old JSON payloads. Unknown callback names remain pending; renaming a handler does not migrate those jobs. TypeScript payload types do not validate data stored by an earlier deployment, so decode evolving payloads explicitly with a schema when needed.

Guides that build on Durable Objects:

Related:

  • Workers — the runtime every DO is reached through.
  • Drizzle migrations — run drizzle-kit’s durable-sqlite migrations inside the object in the constructor.

Reference: