Building Chat and Distributed Locks at the Edge Without Redis or WebSocket Servers — A Practical Guide to Cloudflare Durable Objects
When I tried adding a chat feature to a serverless setup, my first instinct was to spin up a Redis Pub/Sub with a Socket.io server — it was the familiar pattern. But the moment I tried to run that stack on Workers, I hit a wall immediately. Workers are stateless functions that can't hold connection state directly, and placing a Redis instance for Pub/Sub somewhere separate eliminates the latency advantage of running at the edge. The instant a network hop to Redis exists, roughly half the point of running at the edge disappears.
Cloudflare Durable Objects (DO) approaches this problem from the opposite direction. Instead of asking "how do we attach state to a stateless Worker?", it creates a single instance with persistent state and a dedicated SQLite database from the very start. One chat room = one DO instance, one document = one DO instance. Because all requests for a given room are serialized through a single instance, the distributed locks you used to painstakingly build with Redis SETNX or Lua scripts become structurally unnecessary.
This article walks through the core mechanics of DO, implementing a chat room with WebSocket Hibernation, and SQLite-based distributed locks — all with real code. DO has been available on the Free plan since April 2025, and the SQLite storage backend is now generally available. Note that SQLite usage billing has been in effect since January 2026, so be sure to check per-plan limits on the official pricing page before adopting it.
Core Concepts
DO Is "One Single-Threaded Process per Entity"
Regular Workers run in an independent V8 instance per request. Sharing state requires an external store like KV, D1, or Redis. DO is different. A single instance with a globally unique ID is created, and that instance holds its own memory and SQLite while processing requests on a single thread.
The reason single-threaded execution matters is race condition elimination. Even if two people send messages to the same chat room simultaneously, the DO instance for that room processes requests one at a time. Read-modify-write cycles stay intact without any separate locking.
flowchart TD
C1[Client A] --> W1[Worker Edge Node 1]
C2[Client B] --> W2[Worker Edge Node 2]
C3[Client C] --> W3[Worker Edge Node 3]
W1 --> DO[DO Instance room-123]
W2 --> DO
W3 --> DO
DO --> SQLite[Dedicated SQLite]
DO --> Mem[In-Memory State]Requests coming in from multiple Worker edge nodes all converge on a single DO. The Cloudflare network routes to the correct instance based on ID, and serial processing is guaranteed within that instance.
How idFromName() Determines Global Routing
A DO instance is created with a name-based ID like idFromName("room-123"). This ID operates in a content-addressed fashion. Feed it the same name from any Worker edge node anywhere in the world, and you always get the same ID back. The Cloudflare network uses this ID to funnel requests to a single instance.
As a result, code using idFromName(roomId) has the guarantee that "there is exactly one instance for this room ID somewhere on the planet, and requests will always go to that instance." A key design principle follows from this: never use a single DO instance for your entire app. You must distribute instances using natural entities — chat rooms, documents, orders — as keys. The mistake of violating this principle is covered again in the trade-offs section.
Built-in SQLite — Storage With No Network Hop
Each DO instance has a dedicated SQLite DB and can execute SQL directly via sql.exec(). Because storage access happens within the same instance, there is no network hop to an external DB server. To use the SQLite backend, register the class under new_sqlite_classes in wrangler.toml.
# wrangler.toml
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
[durable_objects]
new_sqlite_classes = ["ChatRoom"]export class ChatRoom extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT NOT NULL,
body TEXT NOT NULL,
ts INTEGER NOT NULL
)
`);
}
}WebSocket Hibernation — Keep Connections, Reduce Billing
In scenarios like a chat room where clients stay connected but aren't sending messages for extended idle periods, Duration billing accumulates while the DO occupies memory.
Hibernation solves this. When a DO is idle for some time it is evicted from memory, but the Cloudflare network holds onto the WebSocket connections on its behalf. When a new message arrives, the DO is reactivated to handle it.
To enable Hibernation, WebSocket event handlers must be defined as class methods rather than using addEventListener.
export class ChatRoom extends DurableObject {
// Hibernation API — only activated with the class method pattern
async webSocketOpen(ws: WebSocket) { /* called once when a new connection is established */ }
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { /* message received */ }
async webSocketClose(ws: WebSocket, code: number, reason: string) { /* connection closed */ }
async webSocketError(ws: WebSocket, error: unknown) { /* error occurred */ }
// This disables Hibernation
// server.addEventListener('message', handler) ← do not use
}webSocketOpen is called once right after a connection is established. It is not called on Hibernation reactivation, making it the right place for "connection initialization" logic such as sending history to a newly connected client.
Practical Application
Scenario 1: Real-Time Chat Room
Each room becomes an independent DO instance, and all WebSocket connections for that room are managed within that instance.
// chat-room.ts
import { DurableObject } from "cloudflare:workers";
interface Env {
CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}
interface SessionMeta {
userId: string;
username: string;
}
export class ChatRoom extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT NOT NULL,
body TEXT NOT NULL,
ts INTEGER NOT NULL
)
`);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/websocket") {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("WebSocket upgrade required", { status: 426 });
}
const userId = url.searchParams.get("userId") ?? crypto.randomUUID();
const username = url.searchParams.get("username") ?? "Anonymous";
const [client, server] = Object.values(new WebSocketPair()) as [WebSocket, WebSocket];
// Enable Hibernation — use acceptWebSocket
this.ctx.acceptWebSocket(server);
// Preserve session info across Hibernation cycles
server.serializeAttachment({ userId, username } satisfies SessionMeta);
return new Response(null, { status: 101, webSocket: client });
}
return new Response("Not Found", { status: 404 });
}
// Called once immediately after connection is established — ideal time to send history
async webSocketOpen(ws: WebSocket) {
const history = [...this.ctx.storage.sql.exec(
"SELECT user, body, ts FROM messages ORDER BY id DESC LIMIT 50"
)].reverse();
ws.send(JSON.stringify({ type: "history", messages: history }));
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const meta = ws.deserializeAttachment() as SessionMeta;
const { body } = JSON.parse(message as string);
const ts = Date.now();
this.ctx.storage.sql.exec(
"INSERT INTO messages (user, body, ts) VALUES (?, ?, ?)",
meta.username,
body,
ts
);
const payload = JSON.stringify({ type: "message", user: meta.username, body, ts });
for (const peer of this.ctx.getWebSockets()) {
try {
peer.send(payload);
} catch {
// Ignore already-closed connections
}
}
}
async webSocketClose(ws: WebSocket, code: number) {
ws.close(code, "Connection closed");
}
async webSocketError(ws: WebSocket, _error: unknown) {
ws.close(1011, "Server error");
}
}// worker.ts — acts as the router
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const roomId = url.searchParams.get("room") ?? "general";
// Same roomId → always the same DO instance, no matter where in the world it's called from
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
return stub.fetch(request);
},
};The client establishes its initial WebSocket connection through the Worker, after which the Cloudflare network delivers messages directly to the corresponding DO instance.
Fan-out completes entirely within the DO — no Redis Pub/Sub or separate WebSocket gateway needed.
Scenario 2: Distributed Lock Implementation
The single-threaded nature of DO lets you build a clean distributed lock.
First, decide on a routing strategy. Here we use one DO instance per resource (idFromName(resource)). Each instance manages only the one lock it's responsible for, so the SQLite table is kept simple.
# Add to wrangler.toml
[[durable_objects.bindings]]
name = "DISTRIBUTED_LOCK"
class_name = "DistributedLock"
[durable_objects]
new_sqlite_classes = ["ChatRoom", "DistributedLock"]// distributed-lock.ts
import { DurableObject } from "cloudflare:workers";
interface Env {
DISTRIBUTED_LOCK: DurableObjectNamespace<DistributedLock>;
}
export class DistributedLock extends DurableObject {
private static readonly DEFAULT_TTL_MS = 30_000;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// One lock per instance — single-row table
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS lock_state (
owner TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const action = url.pathname.slice(1);
const { owner, ttlMs } = await request.json<{ owner: string; ttlMs?: number }>();
switch (action) {
case "acquire": return Response.json(await this.acquire(owner, ttlMs));
case "release": return Response.json(await this.release(owner));
case "renew": return Response.json(await this.renew(owner, ttlMs));
default: return new Response("Not Found", { status: 404 });
}
}
async acquire(
owner: string,
ttlMs = DistributedLock.DEFAULT_TTL_MS
): Promise<{ acquired: boolean }> {
const expiresAt = Date.now() + ttlMs;
const acquired = await this.ctx.storage.transaction(async () => {
const existing = [...this.ctx.storage.sql.exec(
"SELECT expires_at FROM lock_state LIMIT 1"
)][0] as { expires_at: number } | undefined;
if (existing && existing.expires_at > Date.now()) {
return false;
}
// Expired or absent — replace with insert
this.ctx.storage.sql.exec("DELETE FROM lock_state");
this.ctx.storage.sql.exec(
"INSERT INTO lock_state (owner, expires_at) VALUES (?, ?)",
owner,
expiresAt
);
return true;
});
if (acquired) {
// Alarm auto-cleans on TTL expiry — no external cron needed
await this.ctx.storage.setAlarm(expiresAt + 100);
}
return { acquired };
}
async release(owner: string): Promise<{ released: boolean }> {
return await this.ctx.storage.transaction(async () => {
const existing = [...this.ctx.storage.sql.exec(
"SELECT owner FROM lock_state LIMIT 1"
)][0] as { owner: string } | undefined;
if (!existing || existing.owner !== owner) {
return { released: false };
}
this.ctx.storage.sql.exec("DELETE FROM lock_state");
return { released: true };
});
}
async renew(
owner: string,
ttlMs = DistributedLock.DEFAULT_TTL_MS
): Promise<{ renewed: boolean }> {
const expiresAt = Date.now() + ttlMs;
const result = [...this.ctx.storage.sql.exec(
"UPDATE lock_state SET expires_at = ? WHERE owner = ? RETURNING owner",
expiresAt,
owner
)];
if (result.length > 0) {
// Reset alarm to match the renewed expiry time
await this.ctx.storage.setAlarm(expiresAt + 100);
return { renewed: true };
}
return { renewed: false };
}
async alarm() {
this.ctx.storage.sql.exec(
"DELETE FROM lock_state WHERE expires_at <= ?",
Date.now()
);
}
}The flow from lock acquisition to TTL expiry:
flowchart TD
A[acquire request] --> B{Valid lock exists?}
B -->|Yes| C[Return failure]
B -->|No| D[DELETE then INSERT]
D --> E[Set alarm]
E --> F[Return success]
G[Alarm fires] --> H[Delete expired lock]
I[release request] --> J{Owner matches?}
J -->|No| K[Return failure]
J -->|Yes| L[Delete lock and return success]Even if the client crashes without sending release, the alarm fires after the 30-second TTL and automatically releases the lock. renew() also resets the alarm, so if a crash occurs after renewal the cleanup is still guaranteed at the renewed expiry time.
Scenario 3: Type-Safe DO Calls with Workers RPC
Instead of calling a DO over HTTP, Workers RPC lets other Workers invoke DO methods directly as method calls. Which approach to choose depends on the situation.
| Criterion | HTTP fetch | Workers RPC |
|---|---|---|
| Type safety | Manual serialization/deserialization required | Compile-time type checking |
| Call overhead | JSON serialization + HTTP parsing | Minimal serialization |
| Error handling | Branch on HTTP status codes | Exceptions propagate naturally |
| Best suited for | External exposure or polyglot environments | Internal calls within the same Cloudflare service |
When using RPC, DO methods must be public. The Workers RPC stub only exposes public methods.
// lock-service.ts — facade for RPC exposure
import { WorkerEntrypoint } from "cloudflare:workers";
interface Env {
DISTRIBUTED_LOCK: DurableObjectNamespace<DistributedLock>;
}
export class LockService extends WorkerEntrypoint<Env> {
async acquire(resource: string, owner: string, ttlMs?: number) {
const stub = this.env.DISTRIBUTED_LOCK.get(
this.env.DISTRIBUTED_LOCK.idFromName(resource)
);
return stub.acquire(owner, ttlMs);
}
async release(resource: string, owner: string) {
const stub = this.env.DISTRIBUTED_LOCK.get(
this.env.DISTRIBUTED_LOCK.idFromName(resource)
);
return stub.release(owner);
}
async renew(resource: string, owner: string, ttlMs?: number) {
const stub = this.env.DISTRIBUTED_LOCK.get(
this.env.DISTRIBUTED_LOCK.idFromName(resource)
);
return stub.renew(owner, ttlMs);
}
}// Calling Worker side
export default {
async fetch(request: Request, env: Env) {
// Type-safe call without HTTP serialization
const { acquired } = await env.LOCK_SERVICE.acquire("order-42", "worker-abc");
if (!acquired) {
return new Response("Failed to acquire lock", { status: 409 });
}
// ... critical section processing ...
await env.LOCK_SERVICE.release("order-42", "worker-abc");
return new Response("Done");
},
};Trade-Off Analysis
When DO Shines
| Item | Details |
|---|---|
| Eliminate Redis and WebSocket servers | State management and real-time communication without separate infrastructure significantly reduces operational complexity |
| Structural race condition elimination | Single-threaded execution serializes shared state access. No separate lock logic required |
| Zero-latency SQLite | Storage access happens within the same instance — no network hop |
| WebSocket Hibernation | DO is evicted from memory when idle, pausing Duration billing, while connections remain alive |
| Built-in alarms | Session expiry, lock TTL cleanup, and similar tasks can be handled without an external cron |
| Included in Free plan | DO is included in the free plan as of April 2025. Check the pricing page for quota details |
Situations to Watch Out For
| Item | Details |
|---|---|
| Single-instance throughput limit | A single DO instance has an upper bound on throughput. Check current limits in the official docs; if traffic is extremely concentrated on one entity, consider sharding or an alternative approach |
| 128 MB memory limit | A Workers-wide constraint that makes it unsuitable as a large in-memory cache |
| Cloudflare platform lock-in | Cannot run on other clouds or self-hosted environments |
| SQLite billing | In effect since January 2026. For long-term high-volume storage, consider combining with D1 or R2 |
| Regional placement | DO is created by default near the location of the first request. You can guide the approximate region with a location hint; see the official docs for exact behavior |
Common Mistakes in Practice
Anti-pattern 1: Using DO as a global singleton
// All requests funnel into one instance — bottleneck
const id = env.CHAT_ROOM.idFromName("global-singleton");
// Distribute instances using entities as keys — do it this way
const id = env.CHAT_ROOM.idFromName(roomId);Anti-pattern 2: Misusing blockConcurrencyWhile()
blockConcurrencyWhile and transaction() are tools with different purposes. For storage atomicity, use transaction(). Use blockConcurrencyWhile when you need to block new requests from entering during a period — such as initialization involving external I/O — when the DO might otherwise begin processing other Promises. Wrapping large work in blockConcurrencyWhile means no other requests are processed within it, degrading overall throughput.
// Use transaction() for storage atomicity
await this.ctx.storage.transaction(async () => {
const row = [...this.ctx.storage.sql.exec("SELECT count FROM counters")][0];
this.ctx.storage.sql.exec(
"UPDATE counters SET count = ?",
(row?.count as number ?? 0) + 1
);
});
// Correct use of blockConcurrencyWhile — initialization involving external I/O
async initialize() {
await this.ctx.blockConcurrencyWhile(async () => {
// Block other requests from entering while fetching config from an external API
const config = await fetch("https://config.example.com/settings").then(r => r.json());
this.config = config;
});
}Anti-pattern 3: Holding long-lived connections without Hibernation
Using the server.addEventListener('message', handler) pattern disables Hibernation and incurs continuous Duration billing for as long as the connection is alive. Hibernation is only activated with the webSocketMessage, webSocketOpen, webSocketClose, and webSocketError class method pattern.
DO vs. Traditional Stack Comparison
| Scenario | Traditional approach | With DO |
|---|---|---|
| Real-time chat | Redis Pub/Sub + Socket.io server | Single DO instance handles broadcast |
| Distributed lock | Redis SETNX + Lua script | transaction() + alarm for TTL |
| Session state | External KV / session DB | DO in-memory + SQLite |
| Rate limiter | Upstash Redis | DO instance per user ID |
| Collaborative editing | Dedicated operational transformation server + Redis | DO serializes edit events |
Closing Thoughts
The core value of DO comes from a simple principle: "one single-threaded instance per entity." That one principle simultaneously solves race conditions, distributed locking, and real-time broadcasting. Things that previously required complex coordination across Redis and a separate WebSocket server become structurally simple on top of DO.
If you're considering adoption, these questions serve as a decision framework.
Where DO is a natural fit: If you have clearly defined entity boundaries — chat rooms, documents, orders — and traffic is distributed across those entities, DO fits well. If you're already on Cloudflare Workers, you can attach state management without any additional infrastructure, making the adoption cost low.
Where careful evaluation is warranted: If traffic is extremely concentrated on a single entity, you may hit the single-instance throughput limit. If you need to run in environments other than Cloudflare, platform lock-in becomes a real constraint. SQLite billing has been in effect since January 2026, so for large-scale long-term storage needs, consider combining DO with D1 or R2.
Either way, the fastest path to a decision is spinning up a local dev environment on the free plan and building it yourself.
References
- Cloudflare Durable Objects Official Overview
- Zero-latency SQLite storage in every Durable Object
- Use WebSockets · Cloudflare Durable Objects docs
- Rules of Durable Objects
- Durable Objects: Easy, Fast, Correct — Choose three
- SQLite-backed Durable Object Storage API
- GitHub - losfair/dlock
- Liveblocks & Cloudflare Case Study
- Durable Objects on Workers Free plan
- Cloudflare Durable Objects Pricing
- Deploy a real-time chat application