Durable Objects
Real-time shared state: chat rooms, presence, live games, collaborative editing. How to declare a class, write the room, connect clients over WebSockets, and the removal rule that destroys data if you ignore it.
A Durable Object is a single-threaded authority over one piece of live state. One instance per room means no lost updates and instant fan-out to every connected client — the thing a database plus polling can't give you.
Use one when many clients share live mutable state: chat rooms, game rooms, presence indicators, collaborative editing, live auctions.
Pick the right store first:
| Store | For |
|---|---|
| Database | Relational, persistent, queryable across everything |
| KV | Fast cached key lookups |
| Durable Object | A live room every connected client agrees on |
Don't reach for a Durable Object where the database plus polling is enough. It's real machinery with real rules.
Classes versus rooms
The distinction that makes the rest make sense:
- You declare classes at deploy time.
- Rooms are instances created at runtime.
idFromName('room-42')lazily materializes that room on first touch.
So one ChatRoom class can back hundreds of thousands of rooms. Idle rooms hibernate for free.
Adding one — three steps
1. Declare the class in app/durable-objects.json (create it next to package.json):
{ "classes": [{ "binding": "CHAT_ROOM", "class": "ChatRoom" }] }
The platform binds and migrates it on the next deploy. Note that wrangler.toml is not read on deploy — a [[durable_objects]] section there does nothing.
2. Export the class from app/src/index.tsx, either defined there or re-exported:
export { ChatRoom } from './do/chat-room';
If a declared class isn't exported by the bundle, the deploy fails with a clear error. That's deliberate.
3. Type the binding in app/src/types/index.ts:
export interface Env {
// ...
CHAT_ROOM: DurableObjectNamespace;
}
Writing the room
Always use the WebSocket hibernation API — acceptWebSocket plus webSocketMessage / webSocketClose methods — never addEventListener. Hibernation evicts idle rooms from memory, which is what makes thousands of open-but-quiet sockets cost nothing.
export class ChatRoom {
state: DurableObjectState;
constructor(state: DurableObjectState, _env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('Expected WebSocket', { status: 426 });
}
const pair = new WebSocketPair();
this.state.acceptWebSocket(pair[1]); // hibernation-aware accept
return new Response(null, { status: 101, webSocket: pair[0] });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
for (const peer of this.state.getWebSockets()) {
if (peer !== ws) peer.send(message); // fan out to the room
}
}
async webSocketClose(ws: WebSocket, code: number) {
ws.close(code);
}
}
Route upgrades to the room — above the /:lang/* middleware, like /api/*:
app.get('/rooms/:roomId/ws', (c) => {
const id = c.env.CHAT_ROOM.idFromName(c.req.param('roomId'));
return c.env.CHAT_ROOM.get(id).fetch(c.req.raw);
});
Connecting clients
A WebSocket connection is a plain GET with an Upgrade header on your app's normal public URL — platform subdomain and custom domains both work, and upgrade requests bypass the edge cache automatically.
Never ship a bare new WebSocket. Always reconnect with backoff:
function connectRoom(roomId, onMessage) {
let ws, tries = 0, closed = false;
function open() {
ws = new WebSocket('wss://' + location.host + '/rooms/' + roomId + '/ws');
ws.onopen = () => { tries = 0; };
ws.onmessage = (e) => onMessage(JSON.parse(e.data));
ws.onclose = () => {
if (!closed) setTimeout(open, Math.min(1000 * 2 ** tries++, 15000));
};
}
open();
return {
send: (m) => { if (ws.readyState === 1) ws.send(JSON.stringify(m)); },
close: () => { closed = true; ws.close(); },
};
}
Use a JSON envelope ({ type, ...payload }) for every message in both directions, never bare strings, so rooms can evolve without breaking clients.
Authorization happens in the Hono route, before forwarding to the room. Pages served by the same app send their session cookie on the upgrade automatically, so the route can check it as usual. Non-browser clients — mobile apps, other servers — have no cookie: have them fetch a short-lived room token from an authenticated /api/* route and pass it as a query parameter (/rooms/:id/ws?token=...), verified in the route.
Rules and gotchas
- Per-room state lives in
this.state.storage(.get/.put/.delete/.list, plus.sql.exec(...)), durable across hibernation and restarts. In-memory fields are a cache only — they vanish on hibernation, so rebuild them lazily from storage. - A room's storage is reachable only through that room. Use the database for cross-room queries and history — persist chat transcripts to a table asynchronously if you need to search them.
- Per-room scheduled work is
this.state.storage.setAlarm(ts)plus analarm()method — game ticks, room expiry. App-level cron stays with routines; don't poll rooms from a routine. - Removing a class permanently destroys every room's storage. The deploy rejects the removal until you acknowledge it with
"confirmDelete": ["ClassName"]in the same file. Renaming a class is a delete plus an add — storage does not follow the name. Renaming only the binding is free. - Each environment has independent room state, like the database and KV. Staging rooms never touch production rooms.
- Verifying a deployed room needs a real WebSocket client. Node before 22 has no global
WebSocket, so installwsin a scratch directory or test from a browser.curlonly works with--http1.1— and a hanging curl after a101means success, the socket is open. - The first upgrade after a fresh deploy can fail once (cold start). Retry — and note this is exactly why shipped clients must reconnect.
Check what's actually deployed with:
sf durable-objects
Next
- Routing and pages — where the upgrade route has to sit.
- Database — for anything you need to query across rooms.
- Build and deploy — how declarations are reconciled.
Frequently asked questions
When should I use a Durable Object instead of the database?
When many clients share live mutable state that they all must agree on — a chat room, a game, presence, a live auction. If polling a table every few seconds is good enough, use the database; a Durable Object is more machinery than most features need.
How many rooms can I have?
You declare classes at deploy time and rooms are created at runtime on first touch, so hundreds of thousands of rooms per class is normal. Idle rooms hibernate at no cost.
Why did my deploy fail after adding a class?
Most likely the class is declared in app/durable-objects.json but not exported from the app's entry file. The deploy checks and fails with a clear error rather than shipping a broken binding.
Can I rename or remove a class?
Removing a class permanently destroys every room's storage, so the deploy rejects it until you acknowledge with confirmDelete in the same file. A rename is a delete plus an add — storage does not follow the name. Renaming just the binding is free.
Why did my first WebSocket connection after a deploy fail?
Cold start. The first upgrade after a fresh deploy can fail once. That's also why every shipped client needs reconnect logic rather than a bare new WebSocket call.