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
- 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
idandhasStoredDatait 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 - 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. - 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 insidewebSocketClosestill 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.
- Socket model: Assumes the Room accepts sockets with
ctx.acceptWebSocket(ws), which is the Hibernation API.getWebSockets()and thewebSocketClose()handler belong to that model. - Getting the Lobby: There's only one Lobby, created under a fixed name (e.g.,
"lobby") rather than a room code. In the examples,this.lobby()is a helper for illustration that wrapsenv.LOBBY.get(env.LOBBY.idFromName("lobby")). - wrangler config: To deploy, you have to bind the Room and Lobby classes under
durable_objects.bindingsand register both classes inmigrations. The actual config file wasn't available for this post.
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:
- Create a room and wait TTL + grace with no activity → does it disappear from the list, and do the viewer sockets close?
- 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?
- Deliberately throw an exception so
removeRoomfails → does the alarm retry and eventually remove the room from the list, and does running twice cause no errors? - 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
| Item | What the record confirms |
|---|---|
| Lobby | Room-creation and list APIs verified; lazy pruning reported as implemented |
| Real-time relay | Leader echo excluded, sync delivered, new viewers receive the last state and marks |
| Leave / close | Locally, count drops 2→1; when the leader closes the room, it's removed from the list and viewer sockets close |
| 1-hour expiry | Only an implementation report. Actual alarm firing not confirmed |
| After deployment | Secure 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.
setAlarm(Date.now()+3600_000). *Real-time connections and leaving were verified on the local dev server.*
wrangler dev, the local dev server. One viewer left, but the listed headcount stayed at 2. 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. getWebSockets(), the connection list, still had the closing socket. After excluding it, the count went from 2 to 1. readyState=3, closed. Both the room removal and the socket close were confirmed.