Building a real-time SaaS on Cloudflare Durable Objects — without losing your mind.
I tried this on a Sunday afternoon. By dinner I had a working multiplayer cursor demo. Here's the path that worked, including the two times I broke it.
Durable Objects let you build real-time, stateful apps without spinning up Redis, a websocket server, a session store and a queue. One DO instance per "room" (per board, per game, per document) and you're 80% of the way to multiplayer. Here's a working pattern with code, including the gotchas that ate my evening.
What we're building
A multiplayer kanban board. Multiple users can edit cards live, see each other's cursors, and the state survives a page refresh. Three Durable Object instances, one Worker, no Redis, no database server, no websocket dependency. Total moving parts: tiny.
The mental model that helped me
Think of a Durable Object as a process you can address by name. It's a tiny stateful actor. You give it an ID (like board:abc123) and Cloudflare guarantees one running instance for that ID, somewhere on the edge. All requests for that ID go to that instance. The instance can hold state in memory and persist it to its built-in storage.
For a multiplayer app, this maps perfectly: one DO per board. All users connect via websocket to the same DO. The DO holds the current state and broadcasts changes.
Step 1 — The skeleton
// src/board.ts
export class Board implements DurableObject {
state: DurableObjectState
sessions: Set<WebSocket> = new Set()
constructor(state: DurableObjectState) {
this.state = state
}
async fetch(req: Request) {
const upgrade = req.headers.get('Upgrade')
if (upgrade !== 'websocket') return new Response('Expected ws', { status: 426 })
const pair = new WebSocketPair()
this.handle(pair[1])
return new Response(null, { status: 101, webSocket: pair[0] })
}
handle(ws: WebSocket) {
ws.accept()
this.sessions.add(ws)
ws.addEventListener('message', (e) => this.broadcast(e.data, ws))
ws.addEventListener('close', () => this.sessions.delete(ws))
}
broadcast(msg: ArrayBuffer | string, sender: WebSocket) {
for (const ws of this.sessions) if (ws !== sender) ws.send(msg)
}
}
That's a working broadcast room. ~30 lines.
Step 2 — Wire it to a Worker
// src/index.ts
export default {
async fetch(req: Request, env: Env) {
const url = new URL(req.url)
const boardId = url.pathname.split('/')[2] // /board/<id>
const id = env.BOARD.idFromName(boardId)
return env.BOARD.get(id).fetch(req)
}
}
And in wrangler.toml:
[[durable_objects.bindings]]
name = "BOARD"
class_name = "Board"
[[migrations]]
tag = "v1"
new_classes = ["Board"]
Step 3 — Persist state across reconnects
Right now, if everyone disconnects and the DO hibernates, state is gone. Use the DO's storage:
// inside Board class
async loadState() {
const cards = await this.state.storage.get('cards')
return cards ?? []
}
async saveState(cards: Card[]) {
await this.state.storage.put('cards', cards)
}
Mistake #1 I made: I forgot to await the put. Two browser tabs would race-write and one would lose data half the time. Don't be me. await the put.
Step 4 — Cursors (the part that feels magical)
For cursors you don't need to persist anything — they're ephemeral. Just broadcast position updates as small JSON messages, throttle on the client at ~60fps, and you're done. The DO ignores them for storage.
// client side
const ws = new WebSocket(`wss://${host}/board/${boardId}`)
let lastSent = 0
window.addEventListener('mousemove', (e) => {
if (Date.now() - lastSent < 16) return
lastSent = Date.now()
ws.send(JSON.stringify({ kind: 'cursor', x: e.x, y: e.y }))
})
Mistake #2: I forgot to throttle and crashed the DO with 200 messages a second per user. The CPU shaping kicked in and the DO got slow. Throttle on the client. Always.
Cost — and this is the fun part
Cloudflare DO pricing: $0.15 per million requests + $0.20 per GB-second of duration above the free tier. For a 100-user board open all day, that's pennies. Compare that to renting a dedicated websocket server at $20/month minimum and you see why Durable Objects exist.
Cloudflare basically rebuilt Erlang's actor model and gave it a billing tier nobody else can match. We win.
Where to go from here
You can stack on top of this:
- Persistence to D1 — for long-term board history, write deltas to a D1 database from the DO every N seconds.
- Auth — verify a JWT in the Worker before
env.BOARD.get(id).fetch(req). - Multiple rooms per user — store a list of board IDs in KV, one per user.
- Presence list — broadcast a "user joined / left" message and let clients render an avatar bar.
If you'd rather not type all this, buildr's agent will scaffold the whole thing if you just say "build me a multiplayer kanban with Durable Objects." It picks the same pattern.
One actor per room. One websocket pair per user. That's the recipe.
Real-time used to mean "stand up Redis, an LB, a websocket server, and a sticky session config." Now it means "name a Durable Object." Let me know if you build something with this — I want to see it.
Tell the agent to build the multiplayer thing.
Same chat, real Durable Objects in your Cloudflare account, deployed in minutes. The agent uses the pattern from this post by default.
Build my app free