Dev Investigation Notes

Fixing a Durable Objects room list, alarm-based expiry, and a WebSocket headcount that ignored departures

This is a translated article. Read the original in Korean ↗

Per-room state lives in a Room Durable Object (DO). The list of active rooms that participants pick from lives in a separate Lobby DO. Expiry is scheduled with an alarm. The wrong headcount after someone left was fixed by excluding the closing socket from the count. These decisions came up while moving a choreography-practice web app from a laptop server to Cloudflare Workers. In the app, each person plays a video they've already prepared, and playback follows the leader's controls. After a room list and a one-hour inactivity expiry were added, local testing showed that the headcount stayed at 2 even after a viewer left.

Conclusion

  1. Keep the list of DO objects separate from your service's list of active rooms. This app was designed so that the Lobby DO manages each room's name, video info, headcount, and last activity time. Cloudflare does have a management API that lists the objects in a namespace. But the id and hasStoredData it returns aren't the app's room metadata, so the object list alone doesn't carry enough information to serve as a lobby screen. Cloudflare List Objects
  2. Refresh the expiry with storage.setAlarm(Date.now()+3600_000) on every leader action. On expiry, the design cleans up sockets, tells the Lobby to delete the room, and clears storage. Alarm handlers run at least once and are retried on failure, so the handler has to be written to give the same result if it runs again (idempotent). Note that this work did not verify that the alarm actually fires.
  3. Check whether a socket that's being closed is still included in the count. In local testing at the time, the diagnosis was that getWebSockets() called inside webSocketClose still included the closing socket. After that socket was excluded, the count dropped from 2 to 1. That doesn't mean every runtime always behaves the same way.

Situation

The app was for practice: when the leader plays, pauses, or seeks, every participant follows along on the same video, which each person has already prepared on their own device. The decision was to share the video files ahead of time and have the cloud only relay sync signals. The goal was to stop having to spin up a server on a laptop every time and connect to it by IP address over the same Wi-Fi.

A VM that kept the existing Node server was also considered, but the work went ahead on Cloudflare. The old server used SSE, a connection type where the server pushes events to the browser. The new design switched to WebSocket, a two-way message connection, and kept the sync, formation, mark, and reload message formats.

Browser
  └─ Worker: pages, room API, WebSocket connection routing
       ├─ 1 Lobby DO: active room list and metadata
       └─ 1 Room DO per room: sync state, sockets, expiry scheduling

In this structure, the Lobby is the entry point for finding a room, and the Room handles real-time communication inside a room. The plan was to manage the room list with the DO's built-in storage instead of an external DB.

The example code below rests on three assumptions. They weren't confirmed in the original session. They're the usual prerequisites you'll need if you follow along.

Room list: listing objects isn't enough

During the design discussion, someone said "there's no API to list all DOs." But the current official docs clearly document a management API that lists objects per namespace, so quoting that line as a platform limitation would be wrong. What the management API gives you (object IDs and whether they have stored data) is different from what participants need (room name, video, headcount, and whether they can join). Cloudflare List Objects

This app has the Lobby issue room codes and store metadata. When a Room's headcount or activity changes, it tells the Lobby. When the room ends, it's removed from the list. Later, implementation was reported for the Lobby's room-creation and list APIs, along with cleanup of stale entries at query time.

So the choice in this case is to manage the app's active room list directly. Two things were not verified: that a single Lobby is enough at any scale, and that a Lobby outage would have no effect at all on rooms already in progress.

Room expiry: scheduling the alarm and cleaning up the list are separate jobs

The requirement was to delete a room after an hour with no leader activity. The design reschedules the alarm on every action, as shown below, and has the expiry handler clean up sockets, the list, and storage.

// Scheduling line proposed in the design discussion. Not a complete Room implementation.
storage.setAlarm(Date.now()+3600_000);

An alarm wakes the DO up later and runs a handler. The official docs say alarms run at least once and are retried on failure, so the real cleanup code has to handle being run again. Cloudflare Alarms

Separately, some rooms can stay in the list because their delete notification was lost. To clean those up, the design adds lazy pruning: on each Lobby query, it checks lastActivity. The original threshold is one hour plus a grace period. The exact grace period isn't in the record, so if you're following along, start with about 5 minutes as a suggested value. If alarms work correctly, pruning is only a safety net and the grace period can be short. If it's too short, though, you can delete a room right after someone used it.

If you apply this design, the Lobby's activity time also has to keep up with the Room's real activity. If you prune based only on stale list values, you can hide rooms that are still in use. This is a condition to check in the design. There's no record of this long-running behavior being measured. The alarm itself is also left in the completion report as "implemented; real-time firing to be checked after deployment."

Wiring the expiry schedule end to end

The original record has only the single scheduling line and an "implementation done" report. What follows is a minimal example written for this post to fill in that gap. It is not the original patch. The method names (touch, lobby, removeRoom) are for illustration.

const TTL = 3600_000;

export class Room extends DurableObject {
  // 1) On every leader action: store activity time + refresh schedule + notify the Lobby
  async touch() {
    const now = Date.now();
    await this.ctx.storage.put("lastActivity", now);
    await this.ctx.storage.setAlarm(now + TTL);      // one alarm per DO, so this overwrites it
    await this.lobby().touchActivity(this.code, now); // also update lastActivity in the list
  }

  // 2) Runs at the scheduled time. Runs at least once, so it must be safe to run again
  async alarm() {
    const last = (await this.ctx.storage.get("lastActivity")) ?? 0;
    if (Date.now() - last < TTL) {                   // if there was activity in the meantime, don't clean up; reschedule
      await this.ctx.storage.setAlarm(last + TTL);
      return;
    }
    for (const ws of this.ctx.getWebSockets()) ws.close(1000, "expired");
    await this.lobby().removeRoom(this.code);        // finish without error even if already removed (idempotent)
    await this.ctx.storage.deleteAll();              // last: if this fails and retries, redoing earlier steps is harmless
  }
}

The order matters. If you clear storage first, lastActivity is gone on a retry and the check gives the wrong answer. Removing the room from the Lobby and closing the sockets should give the same result no matter how many times they run.

How to verify. You don't need to wait an hour. In development only, change TTL to something like 5 seconds and check the following:

  1. Create a room and wait TTL + grace with no activity → does it disappear from the list, and do the viewer sockets close?
  2. Have the leader do something halfway through the TTL → is the room still there at the original expiry time, and does it disappear TTL after that action?
  3. Deliberately throw an exception so removeRoom fails → does the alarm retry and eventually remove the room from the list, and does running twice cause no errors?
  4. Test the Lobby's lazy pruning on its own: block only the delete notification and check whether the stale entry is removed on query.

These steps were not run as part of this work. In the original session, real-time firing was also left to be checked after deployment.

Headcount on leave: match the close event to what gets counted

While verifying the WebSocket relay under wrangler dev, one viewer left but the listed headcount stayed at 2. The diagnosis at the time was that when the close handler runs, the closing socket is still in the result of getWebSockets(). After changing the code to count without that socket, the drop from 2 to 1 was confirmed.

// Reconstructed example illustrating the fix. Not the original patch.
// ws is the socket currently being closed; ctx is the Durable Object's state object.
const remainingSockets = ctx.getWebSockets().filter(s => s !== ws);

This line only shows the principle: leave the current socket out of the count. Some questions are separate policy decisions. Does the count include the leader or only viewers? How do you count one person with several open connections? The record isn't enough to reconstruct the actual role filter or the full counting code, so don't assume remainingSockets.length is the user count.

You also shouldn't generalize that the closing socket is always still there inside webSocketClose. The current docs say getWebSockets() may return connections that are in the middle of closing. Your wrangler config's compatibility_date tells you roughly which behavior your environment has, and if you're unsure, keeping the exclusion filter does no harm. When the web_socket_auto_reply_to_close compatibility flag is on, the state changes to CLOSED before the close handler runs. The flag is on by default for compatibility dates of 2026-04-07 or later. The app's config at the time couldn't be confirmed from this record. Durable Object State · Durable Object Base Class

Counting once you've picked a policy

This reconstructed example assumes one policy: viewer count = number of distinct viewer IDs, excluding the leader. It assumes each socket got its role and device ID through serializeAttachment({role, clientId}) on connect. The real app's field names aren't confirmed in the record.

async webSocketClose(ws, code, reason) {
  ws.close(code, reason);                                  // complete the close handshake
  await this.reportViewers(ws);
}

async reportViewers(closing) {
  const ids = new Set();
  for (const s of this.ctx.getWebSockets()) {
    if (s === closing) continue;                           // exclude the socket that's leaving
    const a = s.deserializeAttachment();
    if (a?.role === "viewer") ids.add(a.clientId);         // duplicate connections from one device count as one person
  }
  await this.lobby().setViewers(this.code, ids.size, Date.now());
}

Call the same function from webSocketError, and right after a connection is accepted, so increases are counted correctly too. To switch the duplicate-connection policy to "number of connections," use a plain counter instead of a Set. This choice decides what the number on screen means, so make it first.

How to verify. Connect 1 leader and 2 viewers and check the listed count is 2 → one viewer leaves and it's 1 → connecting twice with the same clientId still shows 1 (duplicate policy) → the viewer count doesn't change when only the leader leaves → wait at least 3 seconds before checking. The original session's local test confirmed only the 2→1 drop. The rest are checks this post proposes.

In the same round of testing, the leader closed the room and it disappeared from the list, but the viewer socket's close wasn't detected within 1 second. When the wait was raised to 3 seconds and the test re-run, the socket was closed with readyState=3. This means the close was confirmed in that re-test. It does not mean no future close delay could ever be a bug.

What was actually verified

ItemWhat the record confirms
LobbyRoom-creation and list APIs verified; lazy pruning reported as implemented
Real-time relayLeader echo excluded, sync delivered, new viewers receive the last state and marks
Leave / closeLocally, count drops 2→1; when the leader closes the room, it's removed from the list and viewer sockets close
1-hour expiryOnly an implementation report. Actual alarm firing not confirmed
After deploymentSecure context, token auth, wss connection, and sync relay confirmed. This report includes no verification of headcount drops or alarms

Reporter's Notes: room state and list state are separate things

The conversation below reconstructs the confirmed events and design as a dialogue between characters. The questions and hypotheses are there to explain things. Actual measured results are limited to what's cited.

Park Doeun
I'm moving an app where everyone plays a choreography video they've prepared on their own phone, and playback follows the leader's controls. I'm making it so you can connect without a laptop server and pick an open practice room to join.
Jung Bada
So you give each room one Durable Object that owns its state and connections. If you just pull the list of those objects, doesn't that also give you the room list to show participants?
Jung Haneul
Start with what the list needs to show. Room name, video info, headcount. Object IDs alone aren't enough for that.
Park Doeun
That's why there's a dedicated Lobby for the list, and communication inside a room goes to the Room. Room creation and the list API were verified locally.
Park Dohyun
So finding an object and picking a room for people to join are different jobs. You need to decide first what the lobby shows and who's responsible for keeping it updated.
Jung Bada
You decided to close a room if the leader does nothing for an hour. Doesn't that mean keeping a timer running for every room?
Jung Haneul
This design uses alarms, which schedule work to run later. Every leader action refreshes the expiry with setAlarm(Date.now()+3600_000).
Park Doeun
On expiry it cleans up the sockets and tells the Lobby to delete the room. But what's confirmed so far is just the implementation. The test of it actually going off an hour later is still pending.
Jung Bada
If a Room fails to send its delete notification, the lobby could be left showing a dead room. Shouldn't the list check for stale entries too?
Jung Haneul
That's why the design prunes at query time based on last activity. The threshold is an hour plus a grace period. You also have to make sure the activity time is current.
Park Dohyun
Alarms run at least once, so they can run again after a failure. That means the cleanup code has to give the same result if it runs twice, and the storage delete has to come last.
*Real-time connections and leaving were verified on the local dev server.*
Park Doeun
I hooked up WebSocket, the two-way message connection, on wrangler dev, the local dev server. One viewer left, but the listed headcount stayed at 2.
Jung Bada
If someone leaves and the number doesn't change, maybe it's counting the connection that's closing. We should check whether close handling and counting happen at the right moment.
Jung Haneul
webSocketClose is the handler that runs when a connection closes. Leave the socket being handled out of the count there, then check again whether the number changes after someone leaves.
Park Doeun
That was the diagnosis at the time. Inside the handler, getWebSockets(), the connection list, still had the closing socket. After excluding it, the count went from 2 to 1.
Jung Haneul
So in this local re-test, the counting fix worked. Let's not stretch that result to cover the deployed environment or every compatibility setting.
Park Doeun
The close test caught something too. When the leader closed the room, it disappeared from the list, but the viewer connection didn't show as closed within 1 second.
Jung Bada
Going by that alone, it looks like socket cleanup is missing. But it's also possible the short wait just missed the close.
Jung Haneul
To tell those apart, run the same close flow with a longer wait. The re-test raised the wait to 3 seconds.
Park Doeun
On the second look, the viewer connection was at readyState=3, closed. Both the room removal and the socket close were confirmed.
Jung Haneul
So this time there's no evidence of a missing close. The headcount bug and the close-check timing shouldn't be lumped together as one problem.
Park Dohyun
Having code that manages a single room doesn't make the list or the headcount correct on its own. You have to decide when each piece of state gets updated, then verify each result separately.