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.

The Spikefrost Team30 Jul 20265 min read

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 APIacceptWebSocket 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 an alarm() 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 install ws in a scratch directory or test from a browser. curl only works with --http1.1 — and a hanging curl after a 101 means 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

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.