Scrummy: From Redis and Laravel to Cloudflare Durable Objects
Introduction
My team used to run sprint planning with a couple of the existing planning poker tools out there. None of them were bad, exactly, they were just built for someone else’s team: too many settings we didn’t need, too few of the ones we did. At some point that turns into an itch, and I built Scrummy to scratch it, a free planning poker room with no accounts and no setup, tuned to how my own team actually plays. The code is open source, if you want more than the snippets below.
The first version worked. It also ran on more infrastructure than a “type in a URL, vote on a card, see the average” app should need.
The first version was overkill
Scrummy started as a Laravel app with a Postgres database, real user accounts, and teams you’d invite people to, plus a separate Redis-backed websocket server pushing votes around a room in real time. I’d built it as sign-up-and-invite software, then realized nobody wants to create an account just to vote on a story point with their team. So I dropped the whole auth and teams layer: no accounts, just a nickname and a room link, and anyone who has the link can join.
That decision made the rest obvious. If nothing needs to persist past a room’s lifetime, the app doesn’t need a traditional server-plus-database stack running underneath it either.
One Durable Object per room
The rebuild replaced all of it with Cloudflare Workers and a single Durable Object per room.
If you haven’t used one before, picture a Durable Object as a single storage bucket that lives in one specific place on Cloudflare’s network, not copied across every point of presence (PoP) the way a normal Worker request is. A regular Worker runs wherever the request happens to land, closest to whoever’s asking, and remembers nothing between requests. A Durable Object is the opposite: you give it an ID, and every request for that ID gets routed to that same one instance, wherever it actually lives, so it can hold state in memory and handle requests one at a time instead of two copies of itself stepping on each other.
For Scrummy, that ID is the room’s slug. A Worker routes an incoming WebSocket request to the Durable Object for that slug:
const id = env.ROOM.idFromName(slug);
const stub = env.ROOM.get(id);
return stub.fetch(request);idFromName derives that ID from the slug itself, so the routing needs no
lookup table of its own; the same slug always maps to the same object.
There’s no separate process to keep alive between rooms, and no shared
database row two rooms could collide on. Each room is its own small,
isolated unit of state.
Wiring it up is a few lines in wrangler.jsonc:
"durable_objects": {
"bindings": [{ "name": "ROOM", "class_name": "Room" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }]Websockets that don’t cost anything while idle
The part that replaced the Redis server is the WebSocket Hibernation API . A Durable Object can accept a WebSocket connection and then hibernate between messages, holding no memory or compute open, and wake back up when one arrives:
async fetch(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}State that needs to survive hibernation goes into the Durable Object’s own
SQLite storage, and each socket carries its user’s identity with
ws.serializeAttachment, so a room that wakes back up after sitting idle
for a day still knows who’s who without a database lookup. A 30-day alarm
handles cleanup: if nobody’s visited a room in a month, the object deletes
its own storage and disappears.
The full Room object, including how it handles joins, votes, and a
short reconnect grace period for someone who just refreshed the page, is
in worker/room.ts
on GitHub.
Why not just a database
A database would work here too, it’s just the wrong tool for this. The thing Scrummy needs on every vote is simple: tell everyone else in the room. The old stack meant a separate Node process fanning out socket events, with Redis and Postgres nearby for everything else. With a Durable Object, the Worker already holds every open socket for that room in memory, so broadcasting a vote is a loop over connections it already has, not a hop through another service:
private async broadcast(message: ServerMessage): Promise<void> {
const payload = JSON.stringify(message);
for (const ws of this.ctx.getWebSockets()) {
if (ws.readyState === WebSocket.OPEN) ws.send(payload);
}
}Storage only comes into it to survive hibernation, a restart, or the Durable Object migrating between machines. Coordinating the people in the room doesn’t touch it at all.
Conclusion
I wouldn’t reach for a Durable Object for most of what Redis is good at. But for state scoped to a small group of people, that lives for a few days and needs to reach everyone watching the instant it changes, one object per room turned out to be a better match than a server and a database ever were, and it’s one less thing I have to keep running.
Try it yourself at scrummy.dev . If something’s missing or you’d build a piece of it differently, open an issue or a PR on GitHub .