Cloudflare Worker
The Worker receives the request. It checks whether the request is for
/chat and then forwards it to the correct room.
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.
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
Plain Workers can echo. Durable Objects can coordinate rooms.
The Worker receives the request. It checks whether the request is for
/chat and then forwards it to the correct room.
The Durable Object is the actual chat room. It keeps the live WebSocket connections and broadcasts messages.
WebSocket keeps a live connection open, so messages can move instantly between browser and server.
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. |
Wrangler is the cleanest way to create Durable Object projects.
node -v npm -v
npx wrangler login
mkdir champak-durable-chat cd champak-durable-chat
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.
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);
}
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
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("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
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>
Open the client in two tabs or two devices.
Champak.
Student.
main.
The Durable Object binding is the most important concept.
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
This creates env.CHAT_ROOM in your Worker.
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.
for (const [clientId, client] of this.clients.entries()) {
client.socket.send(text);
}
This sends the message to connected users.
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. |
wss:// from HTTPS pages. Do not use ws:// on a secure page.
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
Use these questions to test understanding.
Because a chat room needs a single place to remember connected users and broadcast messages to them.
CHAT_ROOM?
It is the Durable Object binding name. In JavaScript it appears as env.CHAT_ROOM.
ChatRoom?It is the exported Durable Object class that handles the WebSocket room.
idFromName("main") do?
It gets the Durable Object identity for the room named main.
It should start with wss://.