Building Real-Time Collaboration at the Edge with Cloudflare Durable Objects
Durable Objects give you a single-threaded, SQLite-backed actor at the edge with WebSocket hibernation. This post walks through a real-time collaboration counter, shows the billing mechanics, and highlights the trade-offs you'll hit in production.
15 Oct 2025, 16:08 UTC

The Problem: Stateful Edge Applications Without the Operational Burden
You need a real-time collaboration feature—shared cursors, live counters, collaborative editing—but you don't want to provision WebSocket servers, manage Redis clusters, or reason about cross-region consistency. Traditional architectures push state to a central database and stateless compute, adding latency for every interaction. Cloudflare Durable Objects flip this: they bring a single-threaded, strongly consistent compute instance to the edge, co-located with its own SQLite database, while Workers handle the stateless fan-out.
Actor Model at the Edge
Each Durable Object instance has a globally unique identifier (typically derived from a room ID, document key, or user session). The runtime guarantees that only one instance of a given ID executes at a time, anywhere in Cloudflare's network. This single-threaded execution eliminates race conditions without distributed locks. Workers scale horizontally around these objects, forwarding requests via RPC stubs.
The programming model is deliberately minimal: a JavaScript class with a constructor receiving DurableObjectState and Env, plus optional fetch, webSocketMessage, webSocketClose, and alarm handlers. The state.storage API provides a private SQLite database per object (since the 2023 sql storage backend), with ACID transactions and point-in-time recovery.
Worked Example: Real-Time Collaboration Counter
Consider a shared counter where multiple clients increment a value and see updates instantly. The Durable Object owns the count and the set of connected WebSockets.
// counter.ts
export class Counter {
private count = 0;
private sockets: WebSocket[] = [];
constructor(private state: DurableObjectState, private env: Env) {
// Load persisted count on cold start
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.sql.exec(
`SELECT value FROM counter WHERE key = 'count'`
);
this.count = stored[0]?.value ?? 0;
});
}
async fetch(req: Request): Promise {
const upgradeHeader = req.headers.get('Upgrade');
if (upgradeHeader !== 'websocket') {
return new Response('Expected WebSocket', { status: 400 });
}
const [client, server] = Object.values(new WebSocketPair());
this.sockets.push(server);
server.accept();
server.send(JSON.stringify({ type: 'init', count: this.count }));
return new Response(null, { status: 101, webSocket: client });
}
webSocketMessage(ws: WebSocket, message: string) {
const msg = JSON.parse(message);
if (msg.type === 'increment') {
this.count++;
// Persist atomically
this.state.storage.sql.exec(
`INSERT INTO counter (key, value) VALUES ('count', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
this.count
);
// Broadcast to all connected clients
const payload = JSON.stringify({ type: 'update', count: this.count });
this.sockets.forEach(s => s.send(payload));
}
}
webSocketClose(ws: WebSocket) {
this.sockets = this.sockets.filter(s => s !== ws);
}
}
The Worker entry point routes by room ID:
// worker.ts
export default {
async fetch(req: Request, env: Env): Promise {
const url = new URL(req.url);
const roomId = url.pathname.slice(1) || 'default';
const id = env.COUNTER.idFromName(roomId);
const stub = env.COUNTER.get(id);
return stub.fetch(req);
}
} satisfies ExportedHandler;
In wrangler.toml, define the binding and enable SQL storage:
[durable_objects]
bindings = [
{ name = "COUNTER", class_name = "Counter" }
]
migrations = [
{ tag = "v1", new_sqlite_classes = ["Counter"] }
]
Clients connect via wss://your-worker.your-subdomain.workers.dev/room-123. The first connection to a room cold-starts the object (50–200 ms), loads the count from SQLite, and subsequent messages are processed in-memory with sub-millisecond latency. When all sockets close, the object hibernates—serialized to disk, no object-hour charges accrue until the next message arrives.
WebSocket Hibernation and Billing Reality
Hibernation is the economic enabler for long-lived connections. Without it, an idle WebSocket would keep the object in memory, accruing object-hour charges 24/7. With webSocketMessage and webSocketClose handlers defined, the runtime automatically suspends the isolate after the last event handler returns. The next incoming message resumes execution from the same in-memory state (or reconstructs it from the serialized snapshot).
Billing components to watch:
- Object-hours: Wall-clock time the object exists in memory. Hibernated objects do not count.
- Requests: Each WebSocket upgrade counts as one request; subsequent messages over the same connection do not.
- CPU time: Only charged while your code executes.
Trade-offs and Limitations
Single-location consistency. A Durable Object lives in one data center at a time. If that region fails, the object becomes unavailable until Cloudflare's control plane migrates it (typically seconds). For disaster recovery, you must implement your own replication—e.g., periodic exports via point-in-time recovery to R2—or accept the RTO.
Storage ceiling. Each object has a 128 MB SQLite limit (soft). Large blobs belong in R2; store only metadata and indexes in the object.
Cold starts for new IDs. First access to a never-before-seen room ID incurs isolate spin-up. For predictable hot rooms (e.g., scheduled events), pre-warm with a Cron Trigger that calls a no-op RPC method.
Local development gaps. wrangler dev uses an in-memory SQLite shim without fsync durability. Test migrations and persistence semantics against a remote preview deployment (wrangler deploy --dry-run + wrangler tail) before production.
Next Steps
- Run
npm create cloudflare@latest, choose "Workers with Durable Objects", and select the SQL storage backend. - Paste the counter example above, adjust
wrangler.tomlwith your account details, and deploy. - Open two browser tabs to the same room URL; verify increments broadcast instantly.
- In the Cloudflare dashboard > Workers & Pages > your Worker > Durable Objects tab, confirm storage usage shows a few KB and object-hours remain near zero after closing tabs.
- Add a migration: create a
v2entry inmigrationswith a SQL script to add alast_updatedcolumn, then redeploy. The runtime applies it atomically on next object start.
Durable Objects won't replace every database, but for coordination-heavy, latency-sensitive workloads—presence, locking, session state, real-time sync—they eliminate an entire tier of infrastructure. Start with a single room, measure the cold-start and hibernation behavior, then scale the pattern.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.