Skip to content

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.

As with Workers, the contract is schemas only, in a file both sides import:

example/chat/room.contract.ts
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.

A Worker of the app hosts the object. Room.implement takes an Effect that builds the handlers, once per object, when it starts:

example/chat/chat.ts
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 way implement does for a Worker, and takes the alarm’s and the socket’s handlers beside them.
  • The host is an ordinary Worker. chat has no fetch of its own; it’s there to host rooms.

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:

example/chat/api.ts
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:

example/chat/app.ts
export default App.make('talk', { workers: [api, chat] });
$ pnpm infra dev example/chat/app.ts
example/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.

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, plus exec for 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:

visits.contract.ts
export class Visits extends Durable.Object<Visits>()('visits', {
hit: { success: Schema.Finite },
}) {}
counter.ts
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.

A name is the usual way in, but there are others:

front.ts
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.ObjectAddress is a schema (a name or an id), for an address you store or pass on, and at(address) calls it.
  • A key schema on the contract (Durable.Object<Seats>()('seats', methods, { key: Seat })) types byName, so it takes only, say, a branded SeatName.
  • locationHint is 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, so infra dev keeps the names apart instead.

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 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.

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:

example/chat/chat.ts
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.”

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 back

restore 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.

Now the fun part. A contract can declare a socket: what clients send, what they’re sent, and what each connection carries.

example/chat/room.contract.ts
// 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 socket’s handlers go beside the methods. Each message arrives decoded, and Room.Connections sends to everyone connected, typed:

example/chat/chat.ts
// 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.

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:

example/chat/api.ts
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 fromClient
ada 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.

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.

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

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:

browser.ts
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.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:

test/room.test.ts
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.

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.

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.

  • The host exports a class, room as Room, 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. CallError carries Cloudflare’s retryable and overloaded hints, and Rpc.isRetryable reads 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 CallError whose reason is unavailable.
  • plan compares 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.
  • Adding a class to a Worker already deployed needs --atomic on 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’t connect a WebSocket (Cloudflare refuses that across Workers), so clients connect through the Worker that binds the object.
  • An Effect RpcGroup is 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.