Perron has a chat room at every train station in the Netherlands, and one on every train while it runs. You open the app on a platform, and you're in a room with the other people waiting for the same delayed Intercity. When you board, there's a room for the train. When it reaches the end of the line, that room closes.
Every one of those rooms is a Cloudflare Durable Object. So are the station hubs that list them, the per-device profiles, the moderation queue, the rate limiter and about half a dozen other things: thirteen classes in all. We picked Durable Objects early, mostly because the shape fits so naturally. A chat room is a single place where messages arrive in order and get sent to everyone present. A Durable Object is a single-threaded actor with its own storage that you can hold WebSockets open to. The match is almost too neat.
Neat on a whiteboard, anyway. This post is about what we learned turning it into something that holds up in production: how hibernation changes the way you write code, why we split rooms from stations, how alarms do the work timers can't, and the bug where yesterday's train conversation showed up on today's train.
One object per room, not per station
The obvious design is one Durable Object per station, holding all of that station's rooms. We didn't do that, and the reason is in a comment at the top of the hub class: a busy station's rooms shouldn't contend on a single actor.
A Durable Object processes one thing at a time. That's its great strength, because you never have to think about locks, but it also means its throughput is bounded. Amsterdam Centraal on a Friday evening might have a main room, a room for a delayed train, and a couple of rooms people made themselves. If they all lived in one object, every message in every room would queue behind every other message in the station.
So there are two classes. StationHub is one per station. It owns the room registry and the slower content: the station wall, lost and found, outage reports. It also holds a read-only hibernatable WebSocket that streams the live headcount, so the station screen can show how many people are around without joining a room. StationRoom is one per chat room, and it holds that room's sockets and its message history in SQLite.
Messages in one room never wait on another room. The hub only gets involved when rooms are created or listed, which is rare by comparison.
Trains get their own class, TrainRoom, keyed by the GTFS trip ID. It behaves differently enough to deserve one. Its presence rules follow the train's moving position, and its lifetime is tied to the run, which caused the most interesting bug in this post.
Hibernation changes how you write the object
A WebSocket held by a Durable Object normally keeps the object in memory, and an object in memory is billed for its duration. For a chat room that's terrible economics: most of the time, nobody's typing. People sit in a room for twenty minutes waiting for a train and send three messages.
Hibernation is Cloudflare's answer. If you accept the socket through the hibernation API, the runtime can evict your object from memory while the sockets stay open. When a message arrives, the object is woken up, the message is delivered to webSocketMessage(), and then it can go back to sleep. Hibernation-eligible objects that sit idle aren't billed for duration.
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
// Hibernation: the runtime holds the socket while this object sleeps.
this.ctx.acceptWebSocket(server);
server.serializeAttachment({ authorId, handle, displayName, presence, lastHeartbeatAt: now });
return new Response(null, { status: 101, webSocket: client });
The catch is the word "evict". When the object wakes up, it's a fresh instance. Anything you had in memory is gone: your Map of connections, your cached room state, your timers.
That shapes everything. Per-connection state (who this socket belongs to, their display name, whether they're present at the station, when they last sent a heartbeat) can't live in an in-memory map, because the map would silently vanish the first time the object hibernated. It rides on the socket itself, with serializeAttachment() and deserializeAttachment(). When a message arrives, the handler reads the attachment from the socket it came in on, and when something changes, it writes the attachment back.
async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer) {
const attachment = ws.deserializeAttachment() as Attachment;
const event = parseClientEvent(raw);
// … handle it, then persist any change on the socket itself:
ws.serializeAttachment({ ...attachment, lastHeartbeatAt: Date.now() });
}
To reach everyone in the room, you ask the runtime for its sockets with this.ctx.getWebSockets(), and each one carries its own state. There's no list of your own to keep in sync.
The other thing that disappears is timers. A hibernating object doesn't run setTimeout. That sounds minor until you need to notice that someone has gone quiet.
Noticing silence without a timer
In Perron, you can only write in a station's room while you're actually at the station. The app sends a presence heartbeat every sixty seconds, and if it stops for 150 seconds, you drop to read-only. The 150 is deliberate. It's two and a half heartbeats, so one dropped beat on bad station wifi doesn't flip someone who's standing right there into a reader.
With a timer, you'd sweep the room every minute and expire anyone who'd gone silent. A hibernating object can't do that. So we detect silence lazily: on the way in. When someone tries to post, the room first checks how long ago their last heartbeat was, updates their presence if it's expired, and only then decides whether they may write.
if (event.type === "post") {
// A hibernating object runs no timers, so a client that stopped heartbeating
// and walked away is only caught on its way in.
const presence = expirePresence(attachment.presence, attachment.lastHeartbeatAt, now);
if (presence !== attachment.presence) {
patchAttachment(ws, { ...attachment, presence });
this.send(ws, { type: "presence", state: presence });
}
if (!presence.canWrite) return this.refuse(ws, "not_present");
// …
}
It's slightly unusual to think about, but it's correct. The only moment presence matters is the moment someone tries to use it. Someone who walked away and never posts again doesn't need to be expired at all; they'll simply be read-only if they ever try.
The heartbeat is the bill
On Durable Objects, incoming WebSocket messages are billed as requests, at a ratio of 20 messages to one request. Outgoing messages are free. That sounds generous, and it is, but look at what actually arrives at a chat room. Messages are rare. Heartbeats are constant. Every connected phone sends one a minute, whether or not anyone's talking.
Our architecture notes call the heartbeat interval the app's dominant cost lever, and that's accurate. It's most of our WebSocket traffic, and it scales with people sitting in rooms, not with people talking. Halving the interval would roughly double the cost of an idle room. Doubling it would make presence feel sluggish.
Sixty seconds paired with a 150-second staleness window is where we landed. We also built a fallback: a small Node server that implements the same room interface, speaks the same protocol, and checks the same session tokens, so station rooms could move to a box we run ourselves if WebSocket traffic ever got expensive. Both sides are built and the switch is a client config value. It's dormant, and we hope it stays that way. But knowing it exists made it much easier to commit to Durable Objects in the first place.
Hibernation across a deploy
Here's one that bit us. When you deploy new code, objects restart, but hibernated sockets can survive the deploy. The new code then reads attachments that were written by the old code.
We'd added two fields to the train room's attachment to track the furthest stops a rider had reached. A rider who'd connected before the deploy had an attachment without those fields, so they came back as undefined. Then Math.min(undefined, idx) returned NaN, and the coverage calculation went quietly wrong.
The fix was a single character: == null instead of === null, so undefined and null are treated alike. The lesson is bigger than the fix. Treat socket attachments like a stored format with old versions in the wild, because that's what they are. Every new field is optional when you read it, forever.
Alarms do what timers can't
Since a hibernating object has no timers, anything that has to happen later goes through an alarm. A Durable Object has one alarm slot. You set a time with setAlarm(), and the runtime wakes the object and calls alarm() at that time, even if nothing else would have woken it.
Perron uses them everywhere. Station rooms use them to open and close the window for polling a disruption, and to flush statistics. Train rooms use them to purge the conversation when the train reaches the end of the line. Station hubs clean up old wall posts once an hour. The moderation object uses one to send the next batch of posts for review.
Since there's only one alarm per object, an object with several jobs needs to keep its own list of what's due and set the alarm for the earliest. We keep a small table of pending jobs in SQLite and re-arm after each one runs.
There's a trap with alarms that cost us a real share of our quota, which we've written about in our Durable Object cost post: an alarm that re-arms itself unconditionally runs forever. Every alarm should ask, before re-arming, whether there's anything left to do.
Statistics without a subrequest per message
Every room counts things: messages sent, people present, that sort of thing. Those counts end up in one global Stats object, which feeds leaderboards and the admin dashboard.
Calling the global object for every message would be a subrequest per message, and a single global object would become a bottleneck for the whole country. So each room buffers its counts in a small SQLite table, pending_stats, and an alarm flushes the buffer to the global object every sixty seconds in a single call.
this.ctx.storage.sql.exec(
`INSERT INTO pending_stats (metric, at, n) VALUES (?, ?, ?)
ON CONFLICT (metric, at) DO UPDATE SET n = pending_stats.n + excluded.n`,
metric, bucket, n,
);
if ((await this.ctx.storage.getAlarm()) == null) {
await this.ctx.storage.setAlarm(Date.now() + STAT_FLUSH_MS);
}
The buffer is in storage, not memory, so a quiet room's counts survive being evicted. The comment above that function says it better than we can: a counter that costs more than the thing it counts is a bill.
Yesterday's train
Now the bug we promised.
A train room is keyed by GTFS trip ID. You get the object with idFromName(tripId). And idFromName is deterministic: the same name always gives you the same object. That's the whole point of it.
A Dutch daily service runs with the same trip ID every day. So the room for today's 08:14 to Utrecht is the same Durable Object as yesterday's. We knew that, and we had an alarm to purge the conversation when the train reached its terminus. But that alarm was only armed while someone was connected at the terminus. Trains empty out before they arrive. Most of the time, nobody was in the room when it got there, the alarm was never set, and the next morning's first passenger opened the room and saw yesterday's conversation as history.
The fix had three parts. A run is now identified by its origin departure, so a genuinely new run of the same trip ID starts clean. The terminus purge alarm is armed from the first heartbeat of the run, and it reschedules itself through delays, so an abandoned room still empties ten minutes after the train arrives, with nobody present. And a changed origin only counts as a new run once the previous one has provably ended, because some feeds drop stops the train has already passed, and without that guard, leaving and re-entering a room halfway through a journey would wipe the live chat.
The general lesson: an object you address by name is a thing that persists, and names come back. If the real-world thing you're modelling has a lifetime, the object needs to know where that lifetime starts and ends.
Namespacing with names
The same property is useful. Apple's App Review team needs a demo of Perron that works from wherever the reviewer happens to be, without real users in it. We didn't want a flag that every query has to remember to check.
Instead, the demo world uses different names. When a request is in demo mode, the code wraps idFromName(x) so it becomes idFromName('demo:' + x) for the station hubs, rooms, train rooms, stats and profiles. A reviewer opening Amsterdam Centraal gets a completely separate object from the real Amsterdam Centraal. The isolation is structural, not a filter, so there's no query that could accidentally leak real messages into a demo.
The one thing that deliberately isn't forked is moderation. There's one moderation object for both worlds, so the demo is held to the same rules as everything else.
On the flip side: since idFromName will create an object for any string you give it, never pass it something a client controls without checking it first. Validate a station code against your catalogue, a room ID against the registry. Otherwise anyone can create as many empty objects as they like, one URL at a time.
Would we pick Durable Objects again?
Yes, without much hesitation. The single-threaded model meant we wrote a real-time chat backend with no locks, no Redis, and no message broker, and it scales by adding objects rather than servers. Hibernation makes idle rooms close to free.
But it's a different way of writing code. Memory is a cache that can vanish at any time. Timers don't exist. Old state outlives new code. Names are forever. Once you've internalised those four, Durable Objects are lovely to work with. Before that, they'll surprise you in production.
If you're working through the cost side, our posts on Durable Object and SQLite costs and choosing between D1, KV, Durable Objects and R2 pick up where this one leaves off.