Blueprint: a real-time chat room
One Durable Object per room holds every open WebSocket and broadcasts messages to everyone; D1 keeps the history.
What are we building?
A real-time chat room where many people in the same room see each other's messages instantly. We use a WebSocket (a connection that stays open both ways) so the server can push new messages down without the browser asking again and again.
The trick is: who holds all those open connections and makes sure everyone in a room gets every message? The answer is a Durable Object (DO) — a single, always-there mini-server. We give every room its own Durable Object. A small Worker in front just routes each browser to the right room, and D1 (Cloudflare's SQL database) stores the message history.
Think of it like…
Each chat room is a real meeting room with one dedicated host standing at the door. The host (the Durable Object) keeps a list of everyone inside; when someone speaks, the host repeats it to the whole room and writes it in the room's logbook (D1). Different room name = a different host. There is never any confusion about who is in which room.
Who does what?
There are four roles. Each has one clear job — keeping responsibilities separate is what makes the system easy to reason about.
Browser (client)
Opens a WebSocket to the server, shows incoming messages, and sends what the user types. It never talks to other browsers directly.
Worker (router)
A stateless front door. It reads the room name from the URL, finds that room's Durable Object by name, and forwards the connection. No chat logic lives here.
Room Durable Object
The heart of the system. One per room. It holds every open WebSocket for that room, broadcasts each message to all of them, and is the single coordination point.
D1 (history)
A SQL database that stores every message so the conversation survives reloads and the room object restarting. The DO writes one row per message.
Why is a Durable Object the natural "room"? Because the same room name always resolves to the exact same single instance, everywhere on Earth. That single instance becomes the one place where all the room's connections and state live — no locks, no message broker, no race conditions.
The message history table
Live messages flow through the Durable Object in memory; the permanent copy lives in D1. We need just one table, messages, with a row per chat message. room_id ties each message back to the room it belongs to.
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
user TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_room
ON messages (room_id, created_at);Why created_at is a number
We store created_at as an integer — milliseconds from Date.now(). Numbers sort cleanly and are easy to turn into any timezone in the browser. The index on (room_id, created_at) makes "load the last 50 messages for this room" fast.
The message round-trip
Here is the full life of one message — from a browser connecting, to the Durable Object broadcasting it to everyone and saving it to D1.
"Broadcast" = one in, many out
One client sends a single message in; the Durable Object loops over every open socket and sends it back out to all of them — including the original sender, so their own message appears the same way everyone else sees it.
Build it step by step
Create the project and a D1 database
Scaffold a Worker, then create the D1 database that will hold the history. Note the database_id it prints — you paste it into config next.
npm create cloudflare@latest -- chat-room cd chat-room npx wrangler d1 create chat-historyConfigure wrangler.jsonc
Bind the Durable Object class (ROOMS), register a SQLite migration for it, and bind the D1 database (DB). The migration tells Cloudflare ChatRoom is a new SQLite-backed class.
{ "name": "chat-room", "main": "src/index.js", "compatibility_date": "2025-01-01", "durable_objects": { "bindings": [ { "name": "ROOMS", "class_name": "ChatRoom" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] } ], "d1_databases": [ { "binding": "DB", "database_name": "chat-history", "database_id": "<your-d1-database-id>" } ] }Create the messages table
Run the schema.sql from the data-model section against your D1 database so the messages table exists before you write to it.
npx wrangler d1 execute chat-history --remote --file=./schema.sqlWrite the Worker (the router)
The Worker only checks for a WebSocket upgrade, resolves the room's Durable Object by name with env.ROOMS.idFromName(room), and forwards the request. It re-exports ChatRoom so the runtime can find the class.
import { ChatRoom } from "./chat-room.js"; export { ChatRoom }; export default { async fetch(request, env) { const url = new URL(request.url); const room = url.searchParams.get("room") || "lobby"; // Only accept WebSocket upgrade requests if (request.headers.get("Upgrade") !== "websocket") { return new Response("Expected a WebSocket upgrade", { status: 426 }); } // Resolve the ONE Durable Object for this room name const id = env.ROOMS.idFromName(room); const stub = env.ROOMS.get(id); // Forward the upgrade request to that room object return stub.fetch(request); }, };Write the Room Durable Object
The DO completes the WebSocket handshake with acceptWebSocket (hibernation mode). The webSocketMessage handler runs on every incoming message: it saves one row to D1, then broadcasts to every socket returned by getWebSockets().
import { DurableObject } from "cloudflare:workers"; export class ChatRoom extends DurableObject { constructor(ctx, env) { super(ctx, env); this.ctx = ctx; this.env = env; } // Completes the WebSocket handshake and registers the socket async fetch(request) { const url = new URL(request.url); const room = url.searchParams.get("room") || "lobby"; const pair = new WebSocketPair(); const client = pair[0]; const server = pair[1]; // Hibernation: let the runtime hold the socket so the DO can sleep this.ctx.acceptWebSocket(server); server.serializeAttachment({ room }); return new Response(null, { status: 101, webSocket: client }); } // Runs whenever ANY connected client sends a message async webSocketMessage(ws, raw) { const { user, body } = JSON.parse(raw); const { room } = ws.deserializeAttachment(); const created_at = Date.now(); // 1) Persist to D1 history await this.env.DB .prepare("INSERT INTO messages (room_id, user, body, created_at) VALUES (?, ?, ?, ?)") .bind(room, user, body, created_at) .run(); // 2) Broadcast to everyone connected to THIS room const payload = JSON.stringify({ user, body, created_at }); for (const socket of this.ctx.getWebSockets()) { socket.send(payload); } } async webSocketClose(ws, code, reason, wasClean) { ws.close(code, "room closing socket"); } }Connect from the browser
On the front end, open a WebSocket to the Worker with ?room=lobby, render each incoming message, and send the user's text as JSON. Use wss:// (the secure form) in production.
<script> const room = "lobby"; const ws = new WebSocket(`wss://chat-room.example.workers.dev/?room=${room}`); ws.addEventListener("open", () => console.log("connected to", room)); ws.addEventListener("message", (event) => { const msg = JSON.parse(event.data); addLine(`${msg.user}: ${msg.body}`); // render into your chat list }); // Call this when the user submits the chat box function send(user, text) { ws.send(JSON.stringify({ user, body: text })); } </script>Deploy
Publish to Cloudflare's network. Every distinct ?room= value you connect with automatically gets its own Durable Object.
npx wrangler deploy
Key concepts
WebSocket
A connection that stays open in both directions. Unlike a normal request-then-response, the server can push data down any time — perfect for chat.
WebSocketPair
On the server you create a pair of linked sockets: you keep one (server) and hand the other (client) back to the browser inside a 101 response.
Hibernation
With acceptWebSocket(), the runtime holds the connections for you. An idle room object can be evicted from memory yet keep its sockets alive — so thousands of idle connections cost almost nothing.
Broadcast
Loop over ctx.getWebSockets() and send() to each one. That single list of sockets is exactly why one object per room is so convenient — everyone you need is right there.
idFromName()
Turns a room name into a stable object ID. The same name always maps to the same single instance, anywhere — this is how routing to "the" room works.
Why a DO is the room
A room needs one place that holds all members and agrees on message order. A Durable Object is exactly that: a single coordination point with no locks or extra message broker needed.
Pitfalls & tips
One room = one bottleneck
All traffic for a room funnels through its single object (a soft limit of about 1,000 requests/second). That's fine for a chat room, but don't try to push the whole site through one DO — split work by room, document, or user.
Easy mistakes to avoid
- Forgetting to re-export the ChatRoom class from your Worker entry file — the runtime won't find it.
- Using ws:// instead of wss:// in production; browsers block insecure WebSockets on HTTPS pages.
- Doing heavy work inside webSocketMessage and blocking the broadcast — keep it: persist, then broadcast.
- Trusting the message body blindly — validate user and body before writing to D1.
- Expecting the DO's memory to last forever — it can hibernate, so treat D1 as the durable history.
Show history on join
When a client connects, run SELECT * FROM messages WHERE room_id = ? ORDER BY created_at DESC LIMIT 50 against D1 and send those rows first — so newcomers instantly see recent chat instead of an empty room.
Related products
menu_bookOfficial docsopen_in_new