0 to Infinity Lesson

Make a real-time chat system with WebSockets and Durable Objects.

In this lesson we build a real two-way chat system using a static HTML page, a Cloudflare Worker, WebSockets, and a Cloudflare Durable Object that behaves like a live chat room.

Advertisement

0. What are we building?

A browser-based chat room where two users can exchange messages live.

We want this:

Tab 1: Champak joins room main
Tab 2: Student joins room main

Champak sends: Hello
Student receives: Champak: Hello

Student sends: Working!
Champak receives: Student: Working!

The final WebSocket URL will look like this:

wss://champak-durable-chat.champaksworld.workers.dev/chat?room=main

1. The big idea

Plain Workers can echo. Durable Objects can coordinate rooms.

Cloudflare Worker

The Worker receives the request. It checks whether the request is for /chat and then forwards it to the correct room.

Durable Object

The Durable Object is the actual chat room. It keeps the live WebSocket connections and broadcasts messages.

WebSocket

WebSocket keeps a live connection open, so messages can move instantly between browser and server.

Mental model: Worker = reception desk. Durable Object = chat room. WebSocket = live wire.

2. Project files

We will use one Worker file, one Wrangler config file, and one static HTML page.

champak-durable-chat/
  worker.js
  wrangler.toml
  chat.html
File Purpose
worker.js Cloudflare Worker plus Durable Object class.
wrangler.toml Configuration file that declares the Durable Object binding and migration.
chat.html Browser client page that connects to the WebSocket server.

3. Install and use Wrangler

Wrangler is the cleanest way to create Durable Object projects.

  1. Install Node.js. Open terminal and check:
    node -v
    npm -v
  2. Login to Cloudflare.
    npx wrangler login
  3. Create a folder.
    mkdir champak-durable-chat
    cd champak-durable-chat

4. Create wrangler.toml

This file creates the binding between the Worker and Durable Object class.

name = "champak-durable-chat"
main = "worker.js"
compatibility_date = "2026-06-07"

[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]

CHAT_ROOM is the binding name. In JavaScript it becomes env.CHAT_ROOM.

ChatRoom is the exported class name in worker.js.

5. Create worker.js

This is the complete WebSocket chat server.

const hostName = "Champak Roy Programmer's Picnic";

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === "/chat") {
      const upgradeHeader = request.headers.get("Upgrade");

      if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
        return new Response("Expected WebSocket request at /chat", {
          status: 426,
          headers: {
            "content-type": "text/plain; charset=utf-8",
            "cache-control": "no-store"
          }
        });
      }

      const roomName = url.searchParams.get("room") || "main";
      const id = env.CHAT_ROOM.idFromName(roomName);
      const room = env.CHAT_ROOM.get(id);

      return room.fetch(request);
    }

    const origin = url.origin;
    const wssOrigin = origin.replace("https://", "wss://");

    return new Response(
      "HTTP is working.\\n" +
        "Host: " + hostName + "\\n" +
        "Chat WebSocket URL: " + wssOrigin + "/chat?room=main\\n",
      {
        headers: {
          "content-type": "text/plain; charset=utf-8",
          "cache-control": "no-store"
        }
      }
    );
  }
};

export class ChatRoom {
  constructor(state, env) {
    this.state = state;
    this.env = env;
    this.clients = new Map();
  }

  async fetch(request) {
    const url = new URL(request.url);
    const upgradeHeader = request.headers.get("Upgrade");

    if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
      return new Response("Expected WebSocket request", {
        status: 426,
        headers: {
          "content-type": "text/plain; charset=utf-8"
        }
      });
    }

    const roomName = url.searchParams.get("room") || "main";
    const name = cleanName(url.searchParams.get("name") || "Guest");

    const pair = new WebSocketPair();
    const client = pair[0];
    const server = pair[1];

    server.accept();

    const clientId = crypto.randomUUID();

    this.clients.set(clientId, {
      socket: server,
      name,
      joinedAt: new Date().toISOString()
    });

    server.send(
      JSON.stringify({
        type: "system",
        room: roomName,
        clientId,
        message: "Connected to " + hostName + " chat room",
        users: this.getUsers(),
        time: new Date().toISOString()
      })
    );

    this.broadcast(
      {
        type: "system",
        room: roomName,
        message: name + " joined the chat",
        users: this.getUsers(),
        time: new Date().toISOString()
      },
      clientId
    );

    server.addEventListener("message", (event) => {
      let incoming;

      try {
        incoming = JSON.parse(event.data);
      } catch {
        incoming = {
          type: "chat",
          message: String(event.data)
        };
      }

      const current = this.clients.get(clientId);

      if (!current) {
        return;
      }

      const message = cleanMessage(incoming.message || "");

      if (!message) {
        return;
      }

      this.broadcast({
        type: "chat",
        room: roomName,
        clientId,
        name: current.name,
        message,
        users: this.getUsers(),
        time: new Date().toISOString()
      });
    });

    server.addEventListener("close", () => {
      const current = this.clients.get(clientId);
      const leavingName = current ? current.name : "A user";

      this.clients.delete(clientId);

      this.broadcast({
        type: "system",
        room: roomName,
        message: leavingName + " left the chat",
        users: this.getUsers(),
        time: new Date().toISOString()
      });
    });

    server.addEventListener("error", () => {
      this.clients.delete(clientId);
    });

    return new Response(null, {
      status: 101,
      webSocket: client
    });
  }

  broadcast(data, exceptClientId = null) {
    const text = JSON.stringify(data);

    for (const [clientId, client] of this.clients.entries()) {
      if (clientId === exceptClientId) {
        continue;
      }

      try {
        client.socket.send(text);
      } catch {
        this.clients.delete(clientId);
      }
    }
  }

  getUsers() {
    return Array.from(this.clients.values()).map((client) => client.name);
  }
}

function cleanName(name) {
  return String(name || "Guest")
    .replace(/[<>]/g, "")
    .trim()
    .slice(0, 30) || "Guest";
}

function cleanMessage(message) {
  return String(message || "")
    .replace(/[<>]/g, "")
    .trim()
    .slice(0, 1000);
}
Advertisement

6. Deploy the Worker

Deploy with Wrangler so the Durable Object migration is applied correctly.

npx wrangler deploy

After deployment you will get a URL like:

https://champak-durable-chat.champaksworld.workers.dev/

The WebSocket URL becomes:

wss://champak-durable-chat.champaksworld.workers.dev/chat?room=main

7. Create chat.html

This is the static browser page that connects to your WebSocket server.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Cloudflare Durable Object Chat</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />

  <style>
    body {
      margin: 0;
      min-height: 100vh;
      font-family: system-ui, Arial, sans-serif;
      background: #fff7ed;
      color: #1f2937;
      padding: 18px;
    }

    .wrap {
      max-width: 1000px;
      margin: auto;
    }

    .hero {
      background: linear-gradient(135deg, #f59e0b, #fb923c);
      color: white;
      border-radius: 26px;
      padding: 24px;
      margin-bottom: 18px;
    }

    .grid {
      display: grid;
      grid-template-columns: 320px 1fr;
      gap: 18px;
    }

    .card {
      background: white;
      border: 1px solid #fed7aa;
      border-radius: 22px;
      padding: 18px;
      margin-bottom: 18px;
    }

    label {
      display: block;
      font-weight: 800;
      margin: 12px 0 6px;
      color: #7c2d12;
    }

    input {
      width: 100%;
      box-sizing: border-box;
      border: 1px solid #fdba74;
      border-radius: 14px;
      padding: 12px;
      font: inherit;
    }

    button {
      border: 0;
      border-radius: 999px;
      padding: 10px 14px;
      font-weight: 800;
      cursor: pointer;
      background: #f59e0b;
      color: white;
      margin-top: 10px;
    }

    button.danger {
      background: #dc2626;
    }

    .status {
      display: inline-block;
      margin-top: 12px;
      padding: 8px 12px;
      border-radius: 999px;
      background: #fee2e2;
      color: #dc2626;
      font-weight: 800;
    }

    .status.open {
      background: #dcfce7;
      color: #16a34a;
    }

    .messages {
      height: 480px;
      overflow: auto;
      background: white;
      border: 1px solid #fed7aa;
      border-radius: 22px;
      padding: 14px;
      display: flex;
      flex-direction: column;
      gap: 10px;
    }

    .msg {
      max-width: 80%;
      background: #f3f4f6;
      padding: 10px 12px;
      border-radius: 16px;
    }

    .msg.mine {
      align-self: flex-end;
      background: #ffedd5;
      border: 1px solid #fdba74;
    }

    .msg.system {
      align-self: center;
      background: #ecfdf5;
      color: #166534;
      text-align: center;
    }

    .msg strong {
      display: block;
      color: #7c2d12;
      margin-bottom: 4px;
    }

    .composer {
      display: grid;
      grid-template-columns: 1fr auto;
      gap: 10px;
      margin-top: 12px;
    }

    pre {
      background: #111827;
      color: #fef3c7;
      padding: 14px;
      border-radius: 16px;
      white-space: pre-wrap;
      max-height: 220px;
      overflow: auto;
    }

    @media (max-width: 800px) {
      .grid {
        grid-template-columns: 1fr;
      }
    }
  </style>
</head>

<body>
  <main class="wrap">
    <section class="hero">
      <h1>Durable Object Chat</h1>
      <p>Cloudflare Worker + Durable Object + WebSocket</p>
    </section>

    <section class="grid">
      <aside>
        <div class="card">
          <h2>Connection</h2>

          <label for="serverUrl">WebSocket Base URL</label>
          <input
            id="serverUrl"
            value="wss://champak-durable-chat.champaksworld.workers.dev/chat"
          />

          <label for="roomName">Room</label>
          <input id="roomName" value="main" />

          <label for="userName">Your name</label>
          <input id="userName" value="Champak" />

          <div id="status" class="status">Disconnected</div>

          <br />
          <button onclick="connectChat()">Connect</button>
          <button class="danger" onclick="disconnectChat()">Disconnect</button>
          <button onclick="clearChat()">Clear</button>
        </div>

        <div class="card">
          <h2>Debug Log</h2>
          <pre id="log"></pre>
        </div>
      </aside>

      <section class="card">
        <h2>Chat Room</h2>
        <div id="users">Users: none</div>
        <div id="messages" class="messages"></div>

        <div class="composer">
          <input
            id="messageInput"
            placeholder="Type your message and press Enter..."
          />
          <button onclick="sendChatMessage()">Send</button>
        </div>
      </section>
    </section>
  </main>

  <script>
    let ws = null;
    let myClientId = null;

    const statusBox = document.getElementById("status");
    const logBox = document.getElementById("log");
    const messagesBox = document.getElementById("messages");
    const usersBox = document.getElementById("users");
    const messageInput = document.getElementById("messageInput");

    function setStatus(text, connected) {
      statusBox.textContent = text;
      statusBox.className = "status" + (connected ? " open" : "");
    }

    function log(title, data) {
      const time = new Date().toLocaleTimeString();
      let output = "[" + time + "] " + title;

      if (data !== undefined) {
        try {
          output += "\\n" + JSON.stringify(data, null, 2);
        } catch {
          output += "\\n" + String(data);
        }
      }

      logBox.textContent += output + "\\n\\n";
      logBox.scrollTop = logBox.scrollHeight;
    }

    function buildChatUrl() {
      const base = document.getElementById("serverUrl").value.trim();
      const room = document.getElementById("roomName").value.trim() || "main";
      const name = document.getElementById("userName").value.trim() || "Guest";

      return (
        base +
        "?room=" +
        encodeURIComponent(room) +
        "&name=" +
        encodeURIComponent(name)
      );
    }

    function connectChat() {
      const url = buildChatUrl();

      if (ws && ws.readyState === WebSocket.OPEN) {
        log("Already connected.");
        return;
      }

      ws = new WebSocket(url);

      log("Connecting", url);

      ws.onopen = function () {
        setStatus("Connected", true);
        log("Connected.");
        messageInput.focus();
      };

      ws.onmessage = function (event) {
        let data;

        try {
          data = JSON.parse(event.data);
        } catch {
          data = {
            type: "chat",
            name: "Server",
            message: event.data,
            time: new Date().toISOString()
          };
        }

        if (data.clientId) {
          myClientId = data.clientId;
        }

        updateUsers(data.users);
        addMessage(data);
        log("Received", data);
      };

      ws.onerror = function (event) {
        log("WebSocket error", {
          eventType: event.type,
          readyState: ws ? ws.readyState : "no socket"
        });
      };

      ws.onclose = function (event) {
        setStatus("Disconnected", false);
        log("Disconnected", {
          code: event.code,
          reason: event.reason || "No reason given",
          wasClean: event.wasClean
        });
      };
    }

    function disconnectChat() {
      if (!ws) {
        log("No socket exists.");
        return;
      }

      ws.close(1000, "Closed by user");
      ws = null;
      setStatus("Disconnected", false);
    }

    function sendChatMessage() {
      if (!ws || ws.readyState !== WebSocket.OPEN) {
        log("Not connected. Click Connect first.");
        return;
      }

      const message = messageInput.value.trim();

      if (!message) {
        return;
      }

      ws.send(
        JSON.stringify({
          type: "chat",
          message
        })
      );

      messageInput.value = "";
      messageInput.focus();
    }

    function addMessage(data) {
      const div = document.createElement("div");

      if (data.type === "system") {
        div.className = "msg system";
        div.textContent = data.message || "System message";
      } else {
        const isMine = data.clientId && data.clientId === myClientId;
        div.className = "msg" + (isMine ? " mine" : "");
        div.innerHTML =
          "<strong>" +
          escapeHtml(data.name || "Guest") +
          "</strong>" +
          escapeHtml(data.message || "");
      }

      messagesBox.appendChild(div);
      messagesBox.scrollTop = messagesBox.scrollHeight;
    }

    function updateUsers(users) {
      if (!Array.isArray(users) || users.length === 0) {
        usersBox.textContent = "Users: none";
        return;
      }

      usersBox.textContent = "Users: " + users.join(", ");
    }

    function clearChat() {
      messagesBox.innerHTML = "";
      logBox.textContent = "";
    }

    function escapeHtml(value) {
      return String(value)
        .replaceAll("&", "&")
        .replaceAll("<", "&lt;")
        .replaceAll(">", "&gt;")
        .replaceAll('"', "&quot;")
        .replaceAll("'", "&#039;");
    }

    messageInput.addEventListener("keydown", function (event) {
      if (event.key === "Enter") {
        event.preventDefault();
        sendChatMessage();
      }
    });

    window.addEventListener("beforeunload", function () {
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.close(1000, "Page closed");
      }
    });
  </script>
</body>
</html>

8. Test the system

Open the client in two tabs or two devices.

  1. In tab 1, set name as Champak.
  2. In tab 2, set name as Student.
  3. Use the same room name: main.
  4. Click Connect in both tabs.
  5. Send a message from tab 1. It should appear in tab 2.
  6. Send a reply from tab 2. It should appear in tab 1.

9. Understanding the important lines

The Durable Object binding is the most important concept.

Binding

[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"

This creates env.CHAT_ROOM in your Worker.

Room selection

const roomName = url.searchParams.get("room") || "main";
const id = env.CHAT_ROOM.idFromName(roomName);
const room = env.CHAT_ROOM.get(id);
return room.fetch(request);

This sends each room to its own Durable Object instance.

Broadcast

for (const [clientId, client] of this.clients.entries()) {
  client.socket.send(text);
}

This sends the message to connected users.

10. Troubleshooting

Common errors and fixes.

Problem Meaning Fix
env.CHAT_ROOM is undefined Binding missing. Check wrangler.toml durable object binding.
Durable Object class not found Migration missing or wrong class name. Check new_sqlite_classes = ["ChatRoom"].
WebSocket closes with code 1006 Worker crashed during connection. Check binding, route, path, and Worker logs.
HTTP works but WSS fails Wrong path or no WebSocket upgrade handling. Use wss://your-worker/chat?room=main.
One tab does not receive messages Different room names. Use same room in both tabs.
Important: Use wss:// from HTTPS pages. Do not use ws:// on a secure page.

11. From 0 to Infinity roadmap

How to improve this chat system step by step.

Level 0: Plain Worker echo WebSocket
Level 1: Durable Object echo WebSocket
Level 2: Two-tab chat
Level 3: Room names with ?room=main
Level 4: User names with ?name=Champak
Level 5: Join and leave messages
Level 6: Online user list
Level 7: Save last 20 messages
Level 8: Add login
Level 9: Add moderation
Level 10: Use WebSocket hibernation for production

12. Revision questions

Use these questions to test understanding.

Q1. Why do we need Durable Objects for chat?

Because a chat room needs a single place to remember connected users and broadcast messages to them.

Q2. What is CHAT_ROOM?

It is the Durable Object binding name. In JavaScript it appears as env.CHAT_ROOM.

Q3. What is ChatRoom?

It is the exported Durable Object class that handles the WebSocket room.

Q4. What does idFromName("main") do?

It gets the Durable Object identity for the room named main.

Q5. What should the WebSocket URL start with?

It should start with wss://.

Advertisement