DEV Community

Mithunish Prabhakaran for Kadal

Posted on

One Durable Object per room: how Kadal Deck runs a live audience on Cloudflare

The presenter view said "1 phone connected". The phone was in a pocket, screen off, and the presenter had already pressed Disconnect. Nothing changed.

That bug, and the Durable Objects behaviour behind it, is a good way into the live room in Kadal Deck, which we launched this week. A talk is one MDX file, and every published deck gets a room: the audience scans a QR code, with no app and no login, and their phones follow the slides, answer polls and quizzes, ask and upvote questions, and react. The presenter's phone can be the remote. It runs on Cloudflare Workers, with one Durable Object per room.

This post is about how we built that room, what we traded away, and what broke.

Why not just link to a polling app?

Most interactive talks use two products: the slides, and a separate audience tool. The speaker says "go to this other site and type code 4417", and the talk splits in two.

  • The audience leaves the talk to join a second thing, often behind a sign-up.
  • The second thing has no idea which slide is showing.
  • The speaker switches windows to show results, in front of everyone.
  • Afterwards, the questions and votes live in someone else's export.

We wanted the poll to be part of the slide: <Poll id="stack" question="What do you deploy on?" options={[...]} /> in the MDX, and the same connection that carries the slide position carries the vote. That means the room has to know the talk, not just the poll.

The shape of it

In short: one Worker on two hostnames. deck.kadal.cc serves the account and dashboard; decks.kadal.cc serves published decks and the APIs a deck calls. Phones and the presenter view open a WebSocket to /api/rooms/<id>/ws on decks.kadal.cc. The Worker checks who they are and forwards the upgrade to that deck's Durable Object (DeckRoom). The room writes its state to its own storage, and writes what should outlive the talk (sessions, questions, poll results) to D1. Published files sit in R2, addressed by SHA-256.

A few details matter here.

Two origins, one Worker. A published deck is somebody's HTML and JavaScript. It used to share an origin with the dashboard and the sign-in cookie. Now decks live on decks.kadal.cc, and the session cookie is __Host-kd_session, which is host-only, so the browser never sends it to the decks origin. A deck has no account authority at all. The presenter proves who they are with a short-lived ticket scoped to that one deck. The page keeps it in memory, and it usually arrives in the URL fragment, which never reaches a server log.

Rooms are named by deck id. When the host serves a published deck, it writes room: deck.id into the page's index.html. The Worker calls ROOMS.idFromName() with that id, so every phone in the world that opens that deck reaches the same object, wherever it runs.

Files are content-addressed. Publishing sends a manifest of path to SHA-256. The Worker answers with the hashes it doesn't have yet, the CLI uploads only those, and the Worker hashes each body again before writing it to R2 at blobs/<sha256>. Republishing after a one-line edit uploads a handful of files, not the whole build.

Why one Durable Object per room

A talk is small, busy, ordered state: one slide position, a few polls, a list of questions, a few hundred phones. A Durable Object gives us one single-threaded place for all of it. Two people upvoting the same question at the same moment can't race, because there is nothing to race against. No locks, no pub/sub layer, no "which server holds this talk" lookup.

The cost is a ceiling. One object handles one room, and we can't spread a room across machines. So we cap the audience by plan, and the room itself enforces it. The Worker passes the cap in a header: 25 viewers at once on Free, 100 on Starter, 500 on Pro, 1,000 on Team. The presenter always gets in.

The Worker, not the socket, decides who you are:

app.get("/api/rooms/:slug/ws", async (c) => {
  const slug = c.req.param("slug");
  if (!validSlug(slug)) return c.text("bad room", 400);
  const wantsPresenter = c.req.query("role") === "presenter";
  // … phone remotes take their own path (pairing, below)
  const presenter = wantsPresenter && (await isPresenter(c, slug));
  if (wantsPresenter && !presenter) return c.text("presenter passcode required", 401);
  if (!presenter) {
    const ok = await verifyTurnstile(c.env, c.req.query("t") ?? null, clientAddress(c));
    if (!ok) return c.text("verification required", 403);
  }
  const headers = new Headers(c.req.raw.headers);
  headers.set(ROLE_HEADER, presenter ? "presenter" : "viewer");
  headers.delete(DETAIL_HEADER); // the full snapshot is the Worker's to ask for
  headers.set(SLUG_HEADER, slug);
  const room = await publishedRoom(c.env, slug);
  if (room) headers.set(CAP_HEADER, String(room.audience));
  else headers.delete(CAP_HEADER);
  return c.env.ROOMS.get(c.env.ROOMS.idFromName(slug)).fetch(new Request(c.req.raw, { headers }));
});
Enter fullscreen mode Exit fullscreen mode

Inside the room, the role is stored on the socket and a hello message can't change it. A viewer that sends a presenter-only message (nav, poll:open, session and 24 others) is ignored without a reply.

How state survives

The room uses WebSocket hibernation (ctx.acceptWebSocket), so an idle room is evicted from memory while its sockets stay open. When a message arrives, the runtime builds the object again and we reload. That splits the state in two.

Per-socket facts (viewer id, current slide, pace vote, raised hand, last ping) live in each socket's attachment via serializeAttachment, which survives hibernation. Room-wide state is one object in Durable Object storage, written at most every 250 ms:

private async load(): Promise<Persisted> {
  if (this.data) return this.data;
  const stored = await this.ctx.storage.get<Persisted>("room");
  // … the phone remote's pairing and the slug load here too
  this.data = stored ?? { state: structuredClone(EMPTY_ROOM), voters: {}, pollVotes: {}, /* … */ };
  // Presence is live, never restored.
  this.data.state.presence = { viewers: 0, presenter: false, bySlide: {}, /* … */ };
  this.data.state.pointer = null;
  return this.data;
}

private save() {
  if (this.saveTimer) return;
  this.saveTimer = setTimeout(() => {
    this.saveTimer = null;
    if (this.data) void this.ctx.storage.put("room", this.data);
  }, 250);
}
Enter fullscreen mode Exit fullscreen mode

Presence is never stored, because it would be wrong the moment it was read back. It is recomputed from the attachments of the sockets that are still open, and it counts people, not sockets: one viewer id on a phone and a laptop is one person.

Two caveats we accept. A crash can lose up to 250 ms of changes. And the per-viewer rate-limit counters live in memory, so they reset when the room hibernates. That's fine, because a room only hibernates when nobody is sending anything.

A Durable Object has one alarm, and ours does three jobs: it drops sockets that have been silent for 90 seconds (clients ping every 25), it closes a quiz when its timer runs out, and during a session it writes a summary to D1 every minute.

Fanning out the presenter's position

When the presenter moves, the room stores the position, serialises the message once and sends the same string to every live socket except the sender:

private broadcast(m: ServerMessage, except?: WebSocket) {
  const s = JSON.stringify(m);
  for (const [ws, a] of this.live(except)) {
    if (a.pending) continue; // a phone remote that hasn't paired yet
    try {
      ws.send(s);
    } catch {
      /* closed */
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The server doesn't track who is "following". Every viewer gets every nav, and the client decides. A viewer starts in step with the presenter. Any move of their own (a key, a swipe, the overview) stops following, and a pill offers to follow again. That keeps the room simple and lets people flip back to the slide with the code sample without fighting the presenter. The presenter still sees where people are, because presence includes a count per slide.

Polls without accounts

There are no audience accounts, so "one vote per person" means one vote per anonymous viewer id. The client makes a random id once and keeps it in localStorage (deck.viewer). The room keeps every answer by that id, so a reconnect restores your vote and a second vote moves it instead of adding one:

case "poll:vote": {
  const p = data.state.polls[m.id];
  if (!p || !p.open) return;
  const votes = (data.pollVotes[m.id] ??= {});
  // … "words" and "multi" polls elided
  const option = p.def.kind === "rating" ? ((m.rating ?? 0) | 0) - 1 : (m.option ?? -1) | 0;
  if (option < 0 || option >= p.counts.length) return;
  const previous = votes[a.viewer] as number | undefined;
  if (previous !== undefined) p.counts[previous] = Math.max(0, (p.counts[previous] ?? 0) - 1);
  else p.total++;
  p.counts[option] = (p.counts[option] ?? 0) + 1;
  votes[a.viewer] = option;
  this.patch({ polls: data.state.polls });
  return;
}
Enter fullscreen mode Exit fullscreen mode

Question upvotes and quiz answers work the same way. To be honest about it: the id is the client's own word. This stops the common accidents (two tabs, a dropped connection, an eager double tap), not a determined script that opens fresh connections. Writing this post turned up a cheaper hole: a second hello on an open socket used to replace its viewer id, so one socket could vote again and again. Now the first hello names the connection for good, and repeats are rate-limited. What raises the cost of a script further is the audience cap, the rate limits below, and a Turnstile check the Worker can demand before a viewer's socket is accepted. We leave that one off, because joining a talk should take one scan and nothing else.

Limits

Every message passes three gates, in this order: presenter-only types from a viewer are dropped, a paired phone remote may only send command, and then a per-viewer, per-type counter over a fixed window:

const LIMITS: Record<string, { n: number; per: number }> = {
  react: { n: 8, per: 2000 },
  "qa:ask": { n: 1, per: 5000 },
  "qa:vote": { n: 10, per: 5000 },
  "poll:vote": { n: 6, per: 5000 },
  "quiz:answer": { n: 3, per: 5000 },
  pointer: { n: 120, per: 2000 },
  draw: { n: 60, per: 2000 },
  // …
};
Enter fullscreen mode Exit fullscreen mode

Everything else is clamped: frames over 64,000 characters are ignored, questions are cut to 280 characters, the room keeps the newest 200 questions, a slide keeps 200 strokes of up to 2,000 points each, and a poll has at most 12 options. The writes to D1 for questions and votes are fire-and-forget. During the talk the room is the source of truth, and D1 is the record afterwards.

What went wrong

Closed sockets that stayed. Back to the phone in the pocket. The room counted every socket ctx.getWebSockets() returned. A socket the room has closed stays in that list until the other end answers the close, and a phone that has gone to sleep never answers. So "1 phone connected" stayed, and Disconnect closed a socket that kept counting. The fix: mark the socket gone in its attachment before closing it, and count only open sockets that aren't marked:

private live(except?: WebSocket): [WebSocket, Attachment][] {
  const out: [WebSocket, Attachment][] = [];
  for (const ws of this.ctx.getWebSockets()) {
    if (ws === except) continue;
    const state = (ws as { readyState?: number }).readyState;
    if (typeof state === "number" && state !== 1) continue;
    const a = this.att(ws);
    if (!a.gone) out.push([ws, a]);
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

We wrote scripts/remote-check.mjs to reproduce it over real WebSockets with a client that never answers the close. Before the fix it reported remotes=1 after Disconnect. After, 0.

A command that ran twice. In the same change we found that the phone's commands went to every presenter socket, including a leftover socket from a page that had never said hello. That window ran each command a second time. Routing now skips sockets that haven't introduced themselves.

The presenter who left. Viewers of our tour deck were offered "Follow the presenter" long after the presenter had gone. The room counted a presenter socket as present until the next sweep dropped it. Now a presenter counts only if it has pinged in the last 90 seconds. We also moved the alarm's rescheduling ahead of the quiz and session work, because a throw there could have stopped the sweeps for good.

The tab that wouldn't stop presenting. Even with that fix, a presenter view left open in a background tab kept presenting, because a hidden tab still pings (Chrome slows its timers to about once a minute, which is inside 90 seconds). The engine now steps a presenter view down to a viewer after 15 minutes hidden, unless a session is live or a phone remote is paired, since a phone may be driving that hidden window on purpose.

What's open and what's hosted

The engine, @kadal/deck, is AGPL-3.0 and on npm. It includes the room client and the protocol types. Import ClientMessage, ServerMessage and RoomState from @kadal/deck/protocol, and your server can't drift from the client. It also ships kadal-deck relay, a small relay so the phone remote works for a deck on a static host.

The room server itself, the DeckRoom class we've quoted here, is not in the engine repo yet. What is public is the protocol and the endpoints a host has to answer, written up in the self-hosting docs, so you can run your own room on whatever you like and point a deck at it with live.api.

Try it

The tour deck has a live room. Open it on a laptop and join from your phone: https://decks.kadal.cc/@kadal-hq/tour/

If you've handled closed-but-listed sockets on Durable Objects another way, we'd like to hear how.

Top comments (2)

Collapse
 
susanacharya profile image
Susan Acharya •

the phone in a pocket with the screen off still looking connected is such a classic. closed sockets on mobile often never send a close event, so a heartbeat ping with a server side timeout is more reliable than trusting disconnect. nice writeup on the room design

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

one object per room is the kind of boring boundary that keeps a live audience from bleeding into each other.