sellIntegration
forum

Blueprint: a real-time chat room

One Durable Object per room holds every open WebSocket and broadcasts messages to everyone; D1 keeps the history.

1Durable Object per room
Concurrent rooms
3Layers wired
insights

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.

meeting_room

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.

schemaHigh-level architecture

WebSocket

WebSocket

WebSocket

idFromName(room)

broadcast

broadcast

broadcast

save history

Browser A

Worker (router)

Browser B

Browser C

Room Durable Object (per room)

D1 (message history)

account_tree

Who does what?

There are four roles. Each has one clear job — keeping responsibilities separate is what makes the system easy to reason about.

devices

Browser (client)

Opens a WebSocket to the server, shows incoming messages, and sends what the user types. It never talks to other browsers directly.

alt_route

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.

hub

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.

database

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.

schemaOne name → one room object

idFromName(general)

User 1

room = general

User 2

User 3

One Room DO (general)

single source of truth

schema

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.

schemamessages entity

contains

ROOMS

text

id

PK

text

name

MESSAGES

int

id

PK

text

room_id

FK

text

user

text

body

int

created_at

sqlschema.sql — create the history table
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);
info

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.

swap_vert

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.

schemaConnect, send, broadcast, persist
"D1""Room DO""Worker""Client B""Client A""D1""Room DO""Worker""Client B""Client A"Client B is already connectedWebSocket upgrade, room=lobbyidFromName lobby, forward request101 accept, connection opensend a chat messageINSERT INTO messagesbroadcast to allbroadcast to all
campaign

"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.

construction

Build it step by step

  1. 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.

    bash
    npm create cloudflare@latest -- chat-room
    cd chat-room
    npx wrangler d1 create chat-history
  2. Configure 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.

    jsonc
    {
      "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>" }
      ]
    }
  3. 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.

    bash
    npx wrangler d1 execute chat-history --remote --file=./schema.sql
  4. Write 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.

    js
    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);
      },
    };
  5. 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().

    js
    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");
      }
    }
  6. 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.

    html
    <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>
  7. Deploy

    Publish to Cloudflare's network. Every distinct ?room= value you connect with automatically gets its own Durable Object.

    bash
    npx wrangler deploy
school

Key concepts

cable

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.

swap_horiz

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.

bedtime

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.

campaign

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.

fingerprint

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.

hub

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.

tips_and_updates

Pitfalls & tips

balance

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.
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.