1---
2title: Durable Objects
3description: Durable Objects are globally-unique stateful instances with transactional storage — define one as an Effect, persist state per key, expose typed RPC methods, and stream values back to the caller.
4---
5
6A Durable Object (DO) is a globally-unique stateful instance
7addressed by name: every request for `"user-123"` — from any Worker,
8anywhere in the world — lands on the *same* instance, with its own
9transactional SQLite-backed storage. That combination of identity +
10storage + single-threaded execution makes DOs the right tool for
11per-entity state: counters, chat rooms, game sessions, WebSocket
12hubs, rate limiters, collaborative documents.
13
14In alchemy a DO is a class with the same two-phase Effect pattern as
15a [Worker](/cloudflare/compute/workers), and every method you return becomes
16a typed RPC method — no schemas, no serialization boilerplate. This
17page builds one from scratch: a `Counter` that keeps a per-key count
18in transactional storage, exposes RPC methods, and streams a
19sequence of numbers back to the caller. It also shows how to
20[schedule durable callbacks](#create-an-object-for-scheduled-work)
21atomically with application writes.
22
23## Create the Counter file
24
25Like Workers, a DO has two `Effect.gen` blocks: the outer one
26resolves the instance's `state` and any shared dependencies, and
27the inner one returns the public API.
28
29Create `src/counter.ts` with the smallest possible DO — empty
30public API, no state yet:
31
32```typescript
33// src/counter.ts
34import * as Cloudflare from "alchemy/Cloudflare";
35import * as Effect from "effect/Effect";
36
37export default class Counter extends Cloudflare.DurableObject<Counter>()(
38 "Counter",
39 Effect.gen(function* () {
40 // Outer (Construction): resolve the instance state and shared
41 // dependencies here.
42 return Effect.gen(function* () {
43 // Inner: return the public API for this instance.
44 return {};
45 });
46 }),
47) {}
48```
49
50:::tip[Why `Counter<Counter>()(...)` ?]
51The empty `()` after `Counter<Counter>` lets TypeScript capture the
52class identity for the typed RPC stub. It's a one-time bit of
53ceremony — the rest of your DO code looks completely normal.
54:::
55
56## Persist the count
57
58Each DO instance has its own key/value storage, backed by SQLite.
59Resolve `Cloudflare.DurableObjectState` in the **outer** Effect,
60then pull the current count out of storage in the inner constructor so it
61survives restarts and hibernation:
62
63```diff lang="typescript"
64Effect.gen(function* () {
65+ const state = yield* Cloudflare.DurableObjectState;
66 return Effect.gen(function* () {
67+ let count = (yield* state.storage.get<number>("count")) ?? 0;
68 return {};
69 });
70});
71```
72
73`Cloudflare.DurableObjectState` is the same per-instance handle
74Cloudflare exposes for `storage`, `setAlarm`, `acceptWebSocket`,
75and friends. We'll use it more in the next part for WebSockets.
76
77:::note
78You resolve the *reference* to `state` in the outer (Construction) Effect,
79but its methods — like `storage.get` — are
80[colored with `RuntimeContext`](/infrastructure-as-effects/layers#the-types-hold-the-boundary),
81so you can only *use* them in the inner (runtime) Effect. That's why
82`state` is yielded above but `state.storage.get(...)` lives below.
83:::
84
85## SQL migrations
86
87For objects that use SQL tables, generate and commit migration files with your
88schema changes before deploying. Load them in the outer Effect and apply them
89in the inner Effect:
90
91```typescript
92Effect.gen(function* () {
93 const migrations = yield* Cloudflare.SqlMigrations("./drizzle");
94
95 return Effect.gen(function* () {
96 yield* migrations.apply().pipe(Effect.orDie);
97 return {};
98 });
99});
100```
101
102Pending SQL runs when each object activates, before its public methods are
103available. See [SQL migrations](/sql/effect-sql/migrations#durable-object-migrations)
104for the full setup, or pass the same `migrations` to
105[`Drizzle.DurableObject({ migrations, relations })`](/sql/drizzle/migrations#durable-object-migrations).
106
107## Add `increment` and `get`
108
109Any function you return from the inner Effect that produces an
110`Effect` becomes a typed RPC method. Add one to mutate the count
111and one to read it:
112
113```diff lang="typescript"
114Effect.gen(function* () {
115 const state = yield* Cloudflare.DurableObjectState;
116 return Effect.gen(function* () {
117 let count = (yield* state.storage.get<number>("count")) ?? 0;
118- return {};
119+ return {
120+ increment: () =>
121+ Effect.gen(function* () {
122+ count += 1;
123+ yield* state.storage.put("count", count);
124+ return count;
125+ }),
126+ get: () => Effect.succeed(count),
127+ };
128 });
129});
130```
131
132Workers will see `counter.increment()` returning
133`Effect<number>` and `counter.get()` returning `Effect<number>`,
134fully type-checked through the Cloudflare RPC machinery.
135
136## Bind the DO to the Worker
137
138Yield the `Counter` class in your Worker's Construction phase to get a
139namespace handle:
140
141```diff lang="typescript"
142// src/worker.ts
143import * as Cloudflare from "alchemy/Cloudflare";
144import * as Effect from "effect/Effect";
145import * as HttpServerResponse from "effect/http/HttpServerResponse";
146+import Counter from "./counter.ts";
147
148export default Cloudflare.Worker(
149 "Worker",
150 { main: import.meta.url },
151 Effect.gen(function* () {
152+ const counters = yield* Counter;
153
154 return {
155 fetch: Effect.gen(function* () {
156 return HttpServerResponse.text("Hello from my Worker!");
157 }),
158 };
159 }),
160);
161```
162
163`yield* Counter` in the constructor registers the DO with the Worker
164(binding + class-migration metadata) and hands you the namespace.
165
166## Call the DO from `fetch`
167
168Inside the `fetch` handler, route `POST /counter/:name` to the DO
169by calling `getByName(...)` to obtain a typed stub, then invoking
170`increment()`:
171
172```diff lang="typescript"
173// src/worker.ts
174import * as Cloudflare from "alchemy/Cloudflare";
175import * as Effect from "effect/Effect";
176+import { HttpServerRequest } from "effect/http/HttpServerRequest";
177import * as HttpServerResponse from "effect/http/HttpServerResponse";
178import Counter from "./counter.ts";
179
180export default Cloudflare.Worker(
181 "Worker",
182 { main: import.meta.url },
183 Effect.gen(function* () {
184 const counters = yield* Counter;
185
186 return {
187 fetch: Effect.gen(function* () {
188+ const request = yield* HttpServerRequest;
189+
190+ if (request.url.startsWith("/counter/") && request.method === "POST") {
191+ const name = request.url.split("/").pop()!;
192+ const next = yield* counters.getByName(name).increment();
193+ return HttpServerResponse.text(String(next));
194+ }
195+
196 return HttpServerResponse.text("Hello from my Worker!");
197 }),
198 };
199 }),
200);
201```
202
203`counters.getByName(name)` returns a typed stub: the Worker sees
204`increment(): Effect<number>` and `get(): Effect<number>` exactly as
205you defined them. Calling `.increment()` round-trips through
206Cloudflare's RPC machinery to the durable instance.
207
208## Deploy and verify
209
210Re-deploy. Alchemy plans a Worker update and a new `Counter`
211namespace:
212
213```sh
214bun alchemy deploy
215```
216
217Add a test that hits `/counter/foo` twice (expecting `1` then `2`)
218and `/counter/bar` once (expecting `1`) — each name addresses its
219own stateful instance:
220
221```diff lang="typescript"
222// test/integ.test.ts
223import * as Cloudflare from "alchemy/Cloudflare";
224import * as Test from "alchemy/Test/Bun";
225import { expect } from "bun:test";
226import * as Effect from "effect/Effect";
227import * as HttpClient from "effect/http/HttpClient";
228import Stack from "../alchemy.run.ts";
229
230const { test, beforeAll, deploy } = Test.make({
231 providers: Cloudflare.providers(),
232 state: Cloudflare.state(),
233});
234
235const stack = beforeAll(deploy(Stack));
236
237+test(
238+ "Counter persists per key",
239+ Effect.gen(function* () {
240+ const { url } = yield* stack;
241+
242+ const a1 = yield* HttpClient.post(`${url}/counter/foo`);
243+ expect(yield* a1.text).toBe("1");
244+
245+ const a2 = yield* HttpClient.post(`${url}/counter/foo`);
246+ expect(yield* a2.text).toBe("2");
247+
248+ const b1 = yield* HttpClient.post(`${url}/counter/bar`);
249+ expect(yield* b1.text).toBe("1");
250+ }),
251+);
252```
253
254```sh
255bun test test/integ.test.ts
256```
257
258## Stream RPC return values
259
260RPC isn't limited to single values — any `Effect` (or `Stream`) you
261return becomes a typed RPC method. Let's add a `tick(n)` method that
262emits a sequence of numbers `100ms` apart, then expose an HTTP route
263that streams them back to the client.
264
265Add `tick` to `src/counter.ts`:
266
267```diff lang="typescript"
268// src/counter.ts
269import * as Cloudflare from "alchemy/Cloudflare";
270import * as Effect from "effect/Effect";
271+import * as Schedule from "effect/Schedule";
272+import * as Stream from "effect/Stream";
273
274export default class Counter extends Cloudflare.DurableObject<Counter>()(
275 "Counter",
276 Effect.gen(function* () {
277 const state = yield* Cloudflare.DurableObjectState;
278 return Effect.gen(function* () {
279 let count = (yield* state.storage.get<number>("count")) ?? 0;
280
281 return {
282 increment: () =>
283 Effect.gen(function* () {
284 count += 1;
285 yield* state.storage.put("count", count);
286 return count;
287 }),
288 get: () => Effect.succeed(count),
289+ tick: (n: number) =>
290+ Stream.iterate(0, (i) => i + 1).pipe(
291+ Stream.take(n),
292+ Stream.schedule(Schedule.spaced("100 millis")),
293+ ),
294 };
295 });
296 }),
297) {}
298```
299
300Forward the stream from the Worker's `fetch` handler. Use
301`HttpServerResponse.stream` to flush each chunk as it arrives:
302
303```diff lang="typescript"
304// src/worker.ts
305import * as Cloudflare from "alchemy/Cloudflare";
306import * as Effect from "effect/Effect";
307+import * as Stream from "effect/Stream";
308import { HttpServerRequest } from "effect/http/HttpServerRequest";
309import * as HttpServerResponse from "effect/http/HttpServerResponse";
310import Counter from "./counter.ts";
311
312export default Cloudflare.Worker(
313 "Worker",
314 { main: import.meta.url },
315 Effect.gen(function* () {
316 const counters = yield* Counter;
317
318 return {
319 fetch: Effect.gen(function* () {
320 const request = yield* HttpServerRequest;
321
322 if (request.url.startsWith("/counter/") && request.method === "POST") {
323 const name = request.url.split("/").pop()!;
324 const next = yield* counters.getByName(name).increment();
325 return HttpServerResponse.text(String(next));
326 }
327
328+ if (request.url.startsWith("/tick/") && request.method === "GET") {
329+ const n = Number(request.url.split("/").pop()!);
330+ const stream = counters.getByName("tick").tick(n).pipe(
331+ Stream.map((i) => `${i}\n`),
332+ Stream.encodeText,
333+ );
334+ return HttpServerResponse.stream(stream, {
335+ headers: { "content-type": "text/plain" },
336+ });
337+ }
338+
339 return HttpServerResponse.text("Hello from my Worker!");
340 }),
341 };
342 }),
343);
344```
345
346Add the test:
347
348```diff lang="typescript"
349// test/integ.test.ts
350import * as Cloudflare from "alchemy/Cloudflare";
351import * as Test from "alchemy/Test/Bun";
352import { expect } from "bun:test";
353import * as Effect from "effect/Effect";
354+import * as Stream from "effect/Stream";
355import * as HttpClient from "effect/http/HttpClient";
356import Stack from "../alchemy.run.ts";
357
358const { test, beforeAll, deploy } = Test.make({
359 providers: Cloudflare.providers(),
360 state: Cloudflare.state(),
361});
362
363const stack = beforeAll(deploy(Stack));
364
365+test(
366+ "tick streams 5 sequential values",
367+ Effect.gen(function* () {
368+ const { url } = yield* stack;
369+
370+ const response = yield* HttpClient.get(`${url}/tick/5`);
371+ const lines = yield* response.stream.pipe(
372+ Stream.decodeText,
373+ Stream.splitLines,
374+ Stream.runCollect,
375+ );
376+ expect([...lines]).toEqual(["0", "1", "2", "3", "4"]);
377+ }),
378+);
379```
380
381The DO produces values lazily, the runtime ferries each chunk back
382to the Worker, and the Worker pipes them straight onto the HTTP
383response — all type-checked end-to-end.
384
385## Schemaless RPC
386
387Everything you just built *is* RPC — and you never declared a
388schema. Any function returning an `Effect` (or a `Stream`) from the
389inner block becomes a typed method, and `getByName(name)` hands
390callers a stub that mirrors those signatures exactly:
391
392```typescript
393const counters = yield* Counter;
394
395const counter = counters.getByName("user-123");
396const value = yield* counter.increment(); // Effect<number>
397```
398
399This schemaless, typed-stub pattern — the DO shape of
400[Schemaless RPC](/apis/schemaless) — is the recommended way to talk
401to a Durable Object. The types flow from the implementation — add a
402method, and every caller sees it immediately; change a return type,
403and every caller that mismatches fails to compile. The same
404mechanism works across Workers: one Worker can host the DO while
405others bind to it by class — see
406[Bind to another Worker's Durable Object](/cloudflare/compute/cross-worker-durable-object).
407
408Alchemy also ships a schema-based `RpcDurableObject` that serves an
409Effect RPC group on the DO's `fetch`. It is no longer the suggested
410path for DO method calls — the schemaless bridge covers typed
411methods and streaming without the ceremony. Its one remaining niche
412is when return values are `Schema.Class` instances whose identity
413must survive the boundary — the schemaless bridge serializes return
414values and strips class identity — see
415[`Cloudflare.RpcDurableObject`](/cloudflare/apis/effect-rpc#sugar-cloudflarerpcdurableobject).
416If you want schema-*validated* procedures (payload validation,
417tagged error schemas shared with external clients), reach for
418[Effect RPC](/cloudflare/apis/effect-rpc) on a Worker instead.
419
420## Place an instance in a region
421
422A Durable Object is created wherever its **first-ever** request came
423from, and it lives there for good. That is the right default when an
424instance's traffic all comes from whoever created it — `getByName(userId)`
425lands next to that user by construction.
426
427It is the wrong default as soon as the name stops tracking geography. A
428pool sharded by hash (`pool:7`), a fixed roster, a shard index — any of
429these can be created by whichever request happened to arrive first, and
430every later caller pays for that accident. A Sydney user hashed onto an
431instance a Frankfurt request created is making a cross-planet round trip
432on every call, forever.
433
434Pass a `locationHint` to say where a **new** instance should be created:
435
436```typescript
437const shard = shards.getByName("pool:apac:3", { locationHint: "apac" });
438```
439
440Hints only apply at creation, so this is safe to add to an existing call
441site: instances that already exist stay where they are, and nothing you
442pass can move them.
443
444Derive the name and the hint from the same region, so each region
445addresses its own shards:
446
447```typescript
448// in a Worker fetch handler
449const region = hintFor(request.cf?.continent); // your own mapping
450const shard = shards.getByName(`pool:${region}:${index}`, {
451 locationHint: region,
452});
453```
454
455:::caution
456Hint the region you name. If two regions can address one name, they race
457to create the single instance behind it and the loser is stuck talking
458across the planet — the hint cannot fix it afterwards.
459:::
460
461Available hints are `wnam`, `enam`, `sam`, `weur`, `eeur`, `apac`, `oc`,
462`afr`, and `me`. Each is best-effort:
463Cloudflare places the instance in the nearest location it can to the
464hinted region, which may not be inside it. Under `alchemy dev` there is
465only one location to place anything in, so hints are accepted and
466ignored — placement is only observable once deployed.
467
468## Call scope vs isolate scope
469
470A Durable Object shares its isolate's layer build with the Worker
471that hosts it — the entrypoint's constructor runs **once per isolate**, and
472every DO activation (including hibernatable-WebSocket wakes, which
473re-run the constructor) reuses it. Each method call, `fetch`,
474`alarm`, or WebSocket event then runs with a **fresh `Scope`**,
475closed via `state.waitUntil` after the call settles — so
476`Effect.addFinalizer` inside a method runs after the RPC response is
477returned, and a returned `Stream` keeps the scope alive until it
478finishes draining.
479
480As everywhere on workerd, there is no isolate-teardown hook: cleanup
481belongs in methods (call scope), not in the class constructor or the Construction phase.
482Per-call resources like `Drizzle.Postgres` pools open lazily inside
483the method and close with its scope (see the
484[SQL connection lifecycle](/sql/effect-sql/lifecycle)). See
485[Instance scope vs request scope](/infrastructure-as-effects/runtime#instance-scope-vs-request-scope)
486for the model across all runtimes.
487
488## Create an object for scheduled work
489
490Use `Alchemy.makeCallback` when work must survive the request and run
491later. Durable Objects persist these jobs in SQLite and deliver them
492through native alarms; a timer or a forked fiber does not provide that
493persistence.
494
495For this example, create a `Document` object that will save its current
496body and schedule a revision snapshot:
497
498```typescript
499// src/document.ts
500import * as Cloudflare from "alchemy/Cloudflare";
501import * as Effect from "effect/Effect";
502
503export default class Document extends Cloudflare.DurableObject<Document>()(
504 "Document",
505 Effect.gen(function* () {
506 const state = yield* Cloudflare.DurableObjectState;
507 return Effect.gen(function* () {
508 return {};
509 });
510 }),
511) {}
512```
513
514Each named `Document` has its own storage and pending jobs, just as each
515named `Counter` has its own count. Bind it to a Worker by yielding the
516`Document` class in the Worker's Construction phase.
517
518## Register a durable callback
519
520Register the handler in the **inner, per-instance Effect**, before
521returning the public methods:
522
523```diff lang="typescript"
524// src/document.ts
525+import * as Alchemy from "alchemy";
526import * as Cloudflare from "alchemy/Cloudflare";
527import * as Effect from "effect/Effect";
528
529// Inside Document's outer Effect:
530const state = yield* Cloudflare.DurableObjectState;
531return Effect.gen(function* () {
532+ const onSnapshot = yield* Alchemy.makeCallback(
533+ "snapshot",
534+ Effect.fn(function* (payload: { revision: string; body: string }) {
535+ yield* state.storage.put(`revisions:${payload.revision}`, payload.body);
536+ }),
537+ );
538 return {};
539});
540```
541
542`onSnapshot` is a typed scheduling handle, not an RPC method. Alchemy
543re-registers its handler whenever the instance is reconstructed. The
544public API is `Alchemy.makeCallback`; Cloudflare Durable Objects are its
545first supported host. Registration in a Worker, in the outer Construction
546Effect, or later inside an RPC method is unsupported.
547
548Payloads must be JSON values: objects, arrays, strings, finite numbers,
549booleans, or `null`. Values such as `Date`, `undefined`, and `BigInt` are
550rejected when scheduling, even if the handler's TypeScript type accepts
551them. Encode dates as strings or timestamps before scheduling.
552
553## Save and schedule atomically
554
555Add a method that writes the document and schedules its snapshot in the
556same storage transaction:
557
558```diff lang="typescript"
559-return {};
560+return {
561+ save: Effect.fn(function* (revision: string, body: string) {
562+ yield* state.storage.transaction(
563+ Effect.gen(function* () {
564+ yield* state.storage.put("document", { revision, body });
565+ yield* onSnapshot.schedule(revision, {
566+ after: "30 seconds",
567+ payload: { revision, body },
568+ });
569+ }),
570+ );
571+ }),
572+};
573```
574
575The application write, pending callback, and native alarm update commit
576or roll back together. This works for SQLite and key/value writes on the
577**same Durable Object**. The handler runs later; external HTTP calls,
578R2 writes, and other objects' storage are outside this transaction.
579
580The existing `storage.transaction((txn) => effect)` overload remains
581supported. Nested transactions on the same fiber join the active
582transaction rather than create a savepoint. Keep transactional operations
583on that fiber: sibling-fiber storage access is rejected, as are further
584writes after `txn.rollback()`. Storage rollback does not undo assignments
585to JavaScript variables.
586
587## Replace a pending callback
588
589Within the same instance, the callback name and job ID identify a pending
590job. Scheduling the same pair again replaces its payload and due time.
591Use `at` instead of `after` for an absolute time:
592
593```typescript
594// Inside a Document method; revision and body are method arguments.
595yield* onSnapshot.schedule(revision, {
596 at: new Date("2026-12-01T09:00:00Z"),
597 payload: { revision, body },
598});
599```
600
601Pass exactly one of `at` or `after`. IDs are independent across callback
602names and across Durable Object instances. Replacing a pending job does
603not interrupt an invocation that is already running.
604
605## Cancel a pending callback
606
607Cancel from existing application logic; no additional RPC method is needed:
608
609```typescript
610yield* onSnapshot.cancel(revision);
611```
612
613Cancellation joins an active storage transaction, succeeds for absent IDs,
614and does not interrupt a running handler. Failures use `Alchemy.CallbackError`.
615
616## Retry failed callbacks
617
618Delivery is **at least once**, not exactly once. A failed or interrupted
619handler leaves its job pending. The default recovery delay is 30 seconds;
620set a finite, positive delay when registering the handler:
621
622```diff lang="typescript"
623const onSnapshot = yield* Alchemy.makeCallback(
624 "snapshot",
625 Effect.fn(function* (payload: { revision: string; body: string }) {
626 yield* state.storage.put(`revisions:${payload.revision}`, payload.body);
627 }),
628+ { retry: { delay: "10 seconds" } },
629);
630```
631
632Alchemy persists a recovery wake before invoking the handler and
633acknowledges the job only after success. A crash between an external write
634and acknowledgement can repeat that write, so use idempotency keys for
635external operations. This example can repeat safely because it writes the
636same revision key and body. Each invocation receives a fresh Effect scope.
637Delivery times are earliest eligible times, not exact execution deadlines.
638
639:::caution[Keep native alarm retries enabled]
640Automatic recovery from instance termination relies on Cloudflare's
641native alarm retries. `state.abort(reason, { retryAlarm: false })` removes
642that guarantee: pending jobs remain stored, but Cloudflare may suppress
643the replacement wake until the native alarm is explicitly rearmed.
644:::
645
646## Upgrade existing schedules
647
648Existing `scheduleEvent` jobs stay in `alchemy_scheduled_events`, with
649their IDs, timestamps, repeat intervals, and JSON payloads intact. Alchemy
650atomically adds separate callback and schema-version tables to the
651previously unversioned database. It rejects an unsupported newer schema
652without modifying its data.
653
654**Keep the existing `alarm` handler**, its `Cloudflare.processScheduledEvents`
655call, and its application-specific dispatch for pending legacy jobs.
656Those jobs are not converted into `makeCallback` registrations, even if
657their payload contains a field named `callback`. Repeating legacy jobs
658continue to repeat until explicitly cancelled.
659
660Legacy events and callbacks share the object's single native alarm;
661Alchemy coordinates the earliest pending timestamp across both queues.
662A callback-only object does not need an `alarm` handler. Avoid manually
663setting or deleting the native alarm while either scheduler owns pending
664work.
665
666Callback names and payload formats are also persisted contracts. Keep
667old registrations while their jobs are pending, and keep new handlers
668compatible with old JSON payloads. Unknown callback names remain pending;
669renaming a handler does not migrate those jobs. TypeScript payload types
670do not validate data stored by an earlier deployment, so decode evolving
671payloads explicitly with a schema when needed.
672
673## Where next
674
675Guides that build on Durable Objects:
676
677- [Accept WebSockets](/cloudflare/compute/hibernatable-websockets) —
678 WebSocket connections that survive hibernation.
679- [Bind to another Worker's Durable Object](/cloudflare/compute/cross-worker-durable-object)
680 — one Worker hosts the DO, others get a typed stub; also covers
681 moving the host to a different Worker with the data intact.
682- [Run a Container](/cloudflare/compute/run-a-container) — a long-lived
683 container managed by a DO.
684- [Workflows](/cloudflare/compute/workflows) — durable multi-step
685 orchestration.
686
687Related:
688
689- [Workers](/cloudflare/compute/workers) — the runtime every DO is reached
690 through.
691- [Drizzle migrations](/sql/drizzle/migrations#durable-object-migrations) —
692 run drizzle-kit's `durable-sqlite` migrations inside the object in the constructor.
693
694Reference:
695
696- [DurableObject API reference](/providers/cloudflare/workers/durableobject)