Durable Objects and WebSockets
A Durable Object is one of Cloudflare’s best ideas: a small server for each thing (a chat room, a game, a user’s cart), found by name, with its own SQLite database, that handles one thing’s requests in one place.
In forge-effect you call one the way you call another Worker, through a contract, with an address in front:
const message = yield* (yield* Room).byName('lobby').post({ user, text });byName('lobby') is the room called lobby, the same one every time,
wherever in the world the call comes from. You’ll build the
chat example:
rooms that keep their last 50 messages, and a WebSocket for everyone
connected to one.
Step 1: The object contract
Section titled “Step 1: The object contract”As with Workers, the contract is schemas only, in a file both sides import:
import { Schema } from 'effect';
import { Durable } from '@forge-effect/infra';
export class Message extends Schema.Class<Message>('Message')({ id: Schema.String, user: Schema.String, text: Schema.String, at: Schema.Date,}) {}
export class MessageNotFound extends Schema.TaggedError<MessageNotFound>()('MessageNotFound', { id: Schema.String }) {}
export class Room extends Durable.Object<Room>()( 'room', { post: { payload: { user: Schema.String, text: Schema.String }, success: Message }, history: { success: Schema.Array(Message) }, remove: { payload: { id: Schema.String }, error: MessageNotFound }, // How many messages it holds, and its name, to check what it is. about: { success: Schema.Struct({ name: Schema.NullOr(Schema.String), messages: Schema.Int }) }, }, // … and a socket, which we'll come to.) {}Durable.Object instead of Worker.Contract, and that’s the whole
difference: methods with a payload, a success and an error, each
optional, exactly as for a Worker.
Step 2: Implement it
Section titled “Step 2: Implement it”A Worker of the app hosts the object. Room.implement takes an Effect
that builds the handlers, once per object, when it starts:
export const room = Room.implement( // Once per room, as it starts, after its migrations. Effect.gen(function* () { const sql = yield* Durable.Sql; const address = yield* Durable.Address; const connections = yield* Room.Connections; return Room.handlers( { post: ({ user, text }) => post(user, text), history: () => sql.all<Row>('SELECT id, user, text, at FROM messages ORDER BY at, rowid').pipe(Effect.map((rows) => rows.map(toMessage)), Effect.orDie), remove: ({ id }) => sql.run('DELETE FROM messages WHERE id = ?', id).pipe( Effect.orDie, Effect.flatMap(({ changes }) => changes === 0 ? Effect.fail(new MessageNotFound({ id })) : Effect.asVoid(connections.broadcast({ _tag: 'removed', id })), ), ), // … about: how many messages, and its name }, { // When the alarm fires: the room keeps its last messages. alarm: () => trim, // … and the socket's handlers }, ); }), { migrations: ['CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, user TEXT, text TEXT, at INTEGER)'], },);
export default Worker.make('chat', { objects: [room] }).bind();A few things to notice:
- The build is a closure. What it
yield*s (sql,address,connections) the handlers share, so they don’t each ask again. Room.handlers(methods, extras)holds the handlers to the contract, the same wayimplementdoes for a Worker, and takes the alarm’s and the socket’s handlers beside them.- The host is an ordinary Worker.
chathas nofetchof its own; it’s there to host rooms.
Step 3: Call it
Section titled “Step 3: Call it”The caller binds Room and picks an object by its address. The chat’s
api Worker is an Effect HttpApi, and each route is a call to a room:
const rooms = HttpApiBuilder.group( TalkApi, 'rooms', Effect.fn(function* (handlers) { const rooms = yield* Room; return handlers.handleAll({ about: ({ params }) => rooms.byName(params.name).about().pipe(Effect.orDie), history: ({ params }) => rooms.byName(params.name).history().pipe(Effect.orDie), post: ({ params, payload }) => rooms.byName(params.name).post(payload).pipe(Effect.orDie), remove: ({ params }) => rooms.byName(params.name).remove({ id: params.id }).pipe(Effect.catchTag('CallError', Effect.die)), }); }),);
// … a route for the socket, and the root
export default Worker.make('api', { http: Http.app(/* … */),}).bind(Room);remove fails with MessageNotFound as its class, and the API answers
that with a 404. Put both Workers in an app and run it:
export default App.make('talk', { workers: [api, chat] });$ pnpm infra dev example/chat/app.tsexample/chat/app.ts, locally (state in .infra/dev, beside it): api http://api.localhost:8787/ chat (no fetch handler: reached through its bindings) and http://127.0.0.1:8787/ goes to api$ curl -X POST localhost:8787/rooms/lobby/messages -H 'content-type: application/json' -d '{"user":"ada","text":"hello"}'{"id":"96cc1a15","user":"ada","text":"hello","at":"2026-10-17T02:07:51.058Z"}$ curl localhost:8787/rooms/lobby{"name":"lobby","messages":1}That’s a real Durable Object in workerd, with its own SQLite, kept in
.infra/dev between runs.
What an object has
Section titled “What an object has”Inside an object, five services are there for the asking:
Durable.Sql: the object’s SQLite, with the same client shape as D1 (all,first,run, plusexecfor a schema), so code moves between the two. Each statement runs at once.Durable.Storage: key-value storage, beside the SQLite (get,put,delete,list).Durable.Alarm: the object’s one alarm (set,get,delete).Durable.Recovery: point-in-time recovery: put the object’s data back as it was at a moment of the last 30 days (below).Durable.Address: the object’s id, and its name, if it was addressed by one.
A visit counter on Durable.Storage is a handful of lines:
export class Visits extends Durable.Object<Visits>()('visits', { hit: { success: Schema.Finite },}) {}export const visits = Visits.implement( Effect.gen(function* () { const storage = yield* Durable.Storage; return Visits.handlers({ hit: () => Effect.gen(function* () { const n = Option.getOrElse(yield* storage.get<number>('count'), () => 0) + 1; yield* storage.put('count', n); return n; }), }); }),);
export default Worker.make('counter', { objects: [visits] }).bind();These exist only inside an object, so the compiler won’t let you use one
anywhere else. yield* Durable.Sql in a Worker’s fetch is an error
(object-service-outside): “Sql is used outside a Durable Object, where
it doesn’t exist”, with the fix of calling the object instead.
Addressing objects
Section titled “Addressing objects”A name is the usual way in, but there are others:
const visits = yield* Visits;const n = yield* visits.byName(name).hit();
// Or by an id, made once and kept:const id = visits.newId();yield* visits.byId(id).hit();// Or by an address, a name or an id, from Durable.ObjectAddress:yield* visits.at({ name }).hit();// Made near Western Europe, if it's new; kept in the EU:yield* visits.byName(name, { locationHint: 'weur' }).hit();yield* visits.jurisdiction('eu').byName(name).hit();Durable.ObjectAddressis a schema (a name or an id), for an address you store or pass on, andat(address)calls it.- A
keyschema on the contract (Durable.Object<Seats>()('seats', methods, { key: Seat })) typesbyName, so it takes only, say, a brandedSeatName. locationHintis a hint: an object already made stays where it is.jurisdiction('eu')(or'fedramp') keeps objects in that jurisdiction; a name there is a different object from the same name outside it. Local workerd has no jurisdictions, soinfra devkeeps the names apart instead.
One call at a time, mostly
Section titled “One call at a time, mostly”workerd runs an object’s calls one after another, letting another in only
where a call waits (a fetch, a call to another object). Effect’s
scheduler would normally add waits of its own, so the host turns them off:
your handlers interleave only where they’d interleave in workerd. (It
matters: with Effect’s yields left on, 49 of 50 concurrent updates were
lost in workerd.)
When a handler waits on something and still mustn’t overlap with another, run one call at a time:
Room.implement(build, { concurrency: 1 });Migrations, and the outdated guard
Section titled “Migrations, and the outdated guard”migrations on implement is a list of SQL statements, run in order
before the build, each once per object, in a transaction with its record
in the object’s storage. Objects are created lazily, all over the world,
so there’s no moment to migrate “the database”: each object migrates
itself when it starts.
Two rules keep this safe:
- Append, never edit. An object that ran migration 1 won’t run it again, edited or not.
- Make the first safe on old data. An object made before its
implementation had migrations has recorded none, so the chat’s first
migration is
CREATE TABLE IF NOT EXISTS.
During a rollout, both versions of your code run for a while. If an object
that a newer version migrated is started by the older one, the
older one doesn’t know those migrations. Rather than run old code on a
new schema, it touches nothing, and every call fails with a CallError
whose reason is outdated. Once the rollout finishes, the new version
takes over.
Alarms
Section titled “Alarms”An object can set one alarm at a time, and its alarm handler runs when
it fires. The chat’s post sets one three seconds out, if none is set,
and the alarm trims the room:
const trim = Effect.flatMap(Durable.Sql, (sql) => sql.run(`DELETE FROM messages WHERE id NOT IN (SELECT id FROM messages ORDER BY at DESC, rowid DESC LIMIT ${kept})`),).pipe(Effect.asVoid, Effect.orDie);
// A post, from a call or a socket: kept, and told to everyone connected.const post = (user: string, text: string) => Effect.gen(function* () { const sql = yield* Durable.Sql; const alarm = yield* Durable.Alarm; const message = new Message({ id: crypto.randomUUID().slice(0, 8), user, text, at: new Date(yield* Clock.currentTimeMillis) }); yield* sql.run('INSERT INTO messages (id, user, text, at) VALUES (?, ?, ?, ?)', message.id, user, text, message.at.getTime()); if (Option.isNone(yield* alarm.get)) { yield* alarm.set((yield* Clock.currentTimeMillis) + 3_000); } yield* (yield* Room.Connections).broadcast({ _tag: 'posted', message }); return message; }).pipe(Effect.orDie);The handler is told { retryCount, isRetry }. A failed alarm is retried
by Cloudflare, so make it safe to run twice. And if your handlers set an
alarm but you gave no alarm handler, that’s a compile error
(alarm-handler-missing): “a handler sets an alarm, and the object has no
alarm handler, so its alarms would run nothing.”
Point-in-time recovery
Section titled “Point-in-time recovery”Cloudflare keeps 30 days of every object’s history, SQL and key-value data both, and can put an object back as it was at any moment of it. A bookmark names a moment:
const recovery = yield* Durable.Recovery;const before = yield* recovery.bookmark; // where it is now// ... a migration, a bulk edit, an agent let loose ...const undo = yield* recovery.restore(before); // put it backrestore takes effect as the object starts again, which it does as
soon as that call is answered: the next call finds the object restored,
its build run again. It returns a bookmark of the moment before the
restore, so restoring that undoes it. recovery.bookmarkAt(date) gives
the bookmark for a moment, for “put it back as it was at noon”.
Failures are RecoveryError: Refused for what Cloudflare turns down
(a bookmark that isn’t one, a time before the object existed), and
Unavailable under infra dev, as workerd keeps no change log locally.
In tests, Testing.object keeps a snapshot after every write, so a
restore goes as it would on Cloudflare.
WebSockets
Section titled “WebSockets”Now the fun part. A contract can declare a socket: what clients send, what they’re sent, and what each connection carries.
// The room's socket: what a client says, and what the room tells everyone// connected, as each message is posted or removed, and as people come and go.export const Say = Schema.TaggedStruct('say', { text: Schema.String });export const Event = Schema.Union([ Schema.TaggedStruct('posted', { message: Message }), Schema.TaggedStruct('removed', { id: Schema.String }), Schema.TaggedStruct('joined', { user: Schema.String }), Schema.TaggedStruct('left', { user: Schema.String }),]);
export class Room extends Durable.Object<Room>()( 'room', { /* … the methods */ }, { socket: { fromClient: Say, toClient: Event, // Who connected, as the api Worker says: the room trusts it. attachment: Schema.Struct({ user: Schema.String }), ping: ['ping', 'pong'], }, },) {}Each message is a JSON text of its schema. The attachment is decided
by the caller (here, who connected), checked, and kept with the
connection. ping: ['ping', 'pong'] is answered without waking the
object.
The object’s side
Section titled “The object’s side”The socket’s handlers go beside the methods. Each message arrives
decoded, and Room.Connections sends to everyone connected, typed:
// Who's connected hears who comes and goes, and says posts.socket: { open: (connection) => Effect.asVoid(connections.broadcast({ _tag: 'joined', user: connection.attachment.user }, { except: connection })), message: (say, connection) => Effect.asVoid(post(connection.attachment.user, say.text)), close: (connection) => Effect.asVoid(connections.broadcast({ _tag: 'left', user: connection.attachment.user }, { except: connection })),},connections.all(tag) lists connections (by a tag, if given), and each
connection can send and close on its own.
The caller’s side
Section titled “The caller’s side”A caller hands the upgrade to an object with connect. RPC can’t carry a
WebSocket, so this goes through the object’s fetch, and the attachment
and tags are checked before it gets there:
const response = yield* (yield* Room).byName(name).connect(request.source as Request, { attachment: { user } });return HttpServerResponse.raw(response);A request that isn’t a WebSocket upgrade is answered 426.
Here are two clients in one room, from a small Node script against
infra dev: Ada says hello, then Bob sends something that isn’t a Say:
ada hears {"_tag":"joined","user":"bob"}ada hears {"_tag":"posted","message":{"id":"f6552e9a","user":"ada","text":"hello","at":"2026-10-17T02:08:14.551Z"}}bob hears {"_tag":"posted","message":{"id":"f6552e9a","user":"ada","text":"hello","at":"2026-10-17T02:08:14.551Z"}}bob closed 1007 room: a message is JSON of its contract's fromClientada hears {"_tag":"left","user":"bob"}The contract is enforced at the door: a message it doesn’t take closes that connection, and the room carries on.
Hibernation
Section titled “Hibernation”The socket runs on Cloudflare’s hibernation API, so a quiet room doesn’t stay running. Between messages the object may sleep, and it starts again (build included) for the next one, its connections, attachments and tags kept. So keep what a connection needs in its attachment (up to 16 KiB) or the object’s storage, not in a variable.
Close codes
Section titled “Close codes”| Code | When |
|---|---|
| 1007 | a message the contract doesn’t take (1003 for a binary one) |
| 1011 | a socket handler died; its cause is logged, not sent |
| 1012 | the object couldn’t start; the client should connect again |
In the browser
Section titled “In the browser”Room.socket is the codec for clients: encode a message, decode an
event. Import the contract in your front end and the two sides can’t
drift:
import { Effect } from 'effect';
import { Room } from './room.contract.ts';
const socket = new WebSocket('wss://talk-api.you.workers.dev/rooms/lobby/socket?user=ada');socket.addEventListener('open', () => socket.send(Room.socket.encode({ _tag: 'say', text: 'hello' })));socket.addEventListener('message', async ({ data }) => { const event = await Effect.runPromise(Room.socket.decode(String(data))); if (event._tag === 'posted') { console.log(`${event.message.user}: ${event.message.text}`); }});Testing
Section titled “Testing”Testing.object(Room, room) runs objects right in your test, each with
its own SQLite in memory, calls encoded as workerd encodes them. Its
alarms fires each alarm that’s set, connect gives you a test client,
and hibernate puts an object to sleep:
import assert from 'node:assert/strict';import { test } from 'node:test';
import { Effect } from 'effect';
import * as Testing from '@forge-effect/infra/testing';import { kept, room } from '../example/chat/chat.ts';import { Room } from '../example/chat/room.contract.ts';
test('the alarm trims a room to its last messages', async () => { const rooms = Testing.object(Room, room); const history = await Effect.runPromise( Effect.gen(function* () { const lobby = (yield* Room).byName('lobby'); for (let i = 0; i < kept + 5; i++) { yield* lobby.post({ user: 'ada', text: `m${i}` }); } assert.equal(yield* rooms.alarms, 1); // fires the alarm that's set return yield* lobby.history(); }).pipe(Effect.provide(rooms)), ); assert.equal(history.length, kept);});
test('what one client says, everyone hears; a room that slept still knows who is who', async () => { const rooms = Testing.object(Room, room); const heard = await Effect.runPromise( Effect.gen(function* () { const ada = yield* rooms.connect('lobby', { attachment: { user: 'ada' } }); const bob = yield* rooms.connect('lobby', { attachment: { user: 'bob' } }); yield* rooms.hibernate('lobby'); // memory gone, connections kept yield* bob.send({ _tag: 'say', text: 'still here' }); return yield* ada.received; }).pipe(Effect.provide(rooms)), ); assert.deepEqual(heard.map((e) => e._tag), ['joined', 'posted']);});A test client also has sendRaw (to send what a broken client might),
closed (how the object closed it) and close. More in
the socket tests.
Requests over HTTP
Section titled “Requests over HTTP”Some objects are best reached with a plain request: one that fronts a
server, or passes a request on. Give the contract { http: true }, and
its stubs get fetch:
class Page extends Durable.Object<Page>()('page', {}, { http: true }) {}
export const page = Page.implement( Page.handlers({}, { fetch: (request) => Effect.succeed(new Response(`you asked for ${new URL(request.url).pathname}`)) }),);
// a caller:const response = yield* (yield* Page).byName('home').fetch(request);The handler’s answer is the response the caller gets, whatever its
status. A handler that fails is answered 500, and the failure logged.
fetch fails, with a CallError, only when the object couldn’t be
reached or couldn’t start. A contract with a socket still takes its
WebSockets through connect.
Removing an object
Section titled “Removing an object”Stop declaring an object and its data isn’t thrown away. The host keeps
exporting a stand-in class with its name, which answers every call with a
CallError, and the object is marked an orphan. infra prune (naming it,
or with --allow-data-loss) then deploys the host with Cloudflare’s
deleted marker, which is when the data goes.
How it works, briefly
Section titled “How it works, briefly”- The host exports a class,
roomasRoom, backed by SQLite, and deploys before the Workers that bind it. An app whose Worker binds an object no Worker hosts doesn’t compile (object-unhosted). - Each call gets its own stub, so a retry never reuses a broken one.
CallErrorcarries Cloudflare’sretryableandoverloadedhints, andRpc.isRetryablereads them, for a call that’s safe to repeat:call.pipe(Effect.retry({ while: Rpc.isRetryable, times: 3 })). - A build that dies resets the object, and the call fails with a
CallErrorwhose reason isunavailable. plancompares an object contract both ways, because during a rollout old callers meet new objects and new callers meet old ones. It says what each side meets, the socket’s messages included, without stopping the ship.- The host keeps a workers.dev URL that only ship’s probe answers: at each rollout step it starts new objects until one runs the new version, so a build that fails there fails the step.
Good to know
Section titled “Good to know”- Adding a class to a Worker already deployed needs
--atomicon ship. - Objects in another app bind like contracts do:
Visits.from('people')binds the class in the Worker that hosts it there, and the caller’s plan checks it’s deployed and takes its calls. See Calling another app. - Handing out one object without binding the class:
Rpc.Stub(Room), in Handing out a stub. A stub can’tconnecta WebSocket (Cloudflare refuses that across Workers), so clients connect through the Worker that binds the object. - An Effect
RpcGroupis a contract too:Rpc.methodsOf(group)turns its requests into methods. - Limits on a connection: up to 10 tags, each up to 256 characters, and an attachment up to 16 KiB.
- Checked in workerd (the chat’s own bundles, 40 posts at once to one room, the alarm trimming it) and live on a real account.
- Containers: give each object a container of its own, running any program.
- Queues: work that happens later, and elsewhere.
- Testing: more on stand-ins and running your app in workerd.