Skip to content
Cloud & Infra Intermediate Tutorial

Build Real-Time Chat with Cloudflare Durable Objects and WebSockets

Ship a chat room with live presence where each room is a single, consistent Durable Object at Cloudflare's edge.

Emeka Okafor
Emeka Okafor
Security Editor · Aug 7, 2026 · 6 min read
Build Real-Time Chat with Cloudflare Durable Objects and WebSockets

What you'll build

A real-time chat service with live presence, where every room is its own Cloudflare Durable Object — a single-threaded instance that owns the room's WebSocket connections and message history, no matter which continent clients connect from. You'll use the WebSocket Hibernation API so idle rooms cost you nothing, and SQLite storage so history survives restarts.

flowchart LR
    A[Client: alice] -->|wss| W[Worker at nearest edge]
    B[Client: bob] -->|wss| W
    W -->|"getByName('lobby')"| DO["ChatRoom DO 'lobby'<br/>(one instance, own SQLite)"]

Prerequisites

Verified against create-cloudflare 2.70.18, Wrangler 4.120.0, and Node.js 24.1.0 on macOS. You need:

  • Node.js 22 or later — create-cloudflare hard-fails on anything older
  • A free Cloudflare account. Durable Objects work on the free plan as long as you use the SQLite storage backend (which we do)
  • Any OS Wrangler supports: macOS 13.5+, Windows 11, or a modern Linux distro

1. Scaffold the project

Use the official Durable Objects template:

npm create cloudflare@latest -- edge-chat --type hello-world-durable-object --lang ts --no-git --no-deploy
cd edge-chat

Answer "No" to the AGENTS.md prompt (or "Yes", it doesn't affect the build). You get a Worker plus a sample MyDurableObject class, with dependencies already installed.

2. Point the config at your ChatRoom class

Open wrangler.jsonc and rename the scaffold's class and binding. The relevant blocks should read:

{
	"name": "edge-chat",
	"main": "src/index.ts",
	"compatibility_date": "2026-08-07",
	"migrations": [
		{
			"new_sqlite_classes": ["ChatRoom"],
			"tag": "v1"
		}
	],
	"durable_objects": {
		"bindings": [
			{
				"class_name": "ChatRoom",
				"name": "CHAT_ROOM"
			}
		]
	}
}

Keep the other generated keys (observability, compatibility_flags, …) as-is. new_sqlite_classes gives each room a private SQLite database and is the only backend allowed on the free plan. Regenerate the Env types so env.CHAT_ROOM type-checks:

npm run cf-typegen

3. Write the ChatRoom Durable Object

Replace the entire contents of src/index.ts with this class (the Worker handler comes in the next step, same file):

import { DurableObject } from "cloudflare:workers";

type Attachment = { username: string };

export class ChatRoom extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env);
		// sql.exec is synchronous, so schema setup in the constructor is safe.
		ctx.storage.sql.exec(
			`CREATE TABLE IF NOT EXISTS messages (
				id INTEGER PRIMARY KEY AUTOINCREMENT,
				username TEXT NOT NULL,
				text TEXT NOT NULL,
				at INTEGER NOT NULL
			)`,
		);
	}

	async fetch(request: Request): Promise<Response> {
		const url = new URL(request.url);
		const username = url.searchParams.get("username") ?? "anon";

		const pair = new WebSocketPair();
		const [client, server] = Object.values(pair);

		// Hibernation-aware accept: the runtime holds the connection open
		// even while this object is evicted from memory.
		this.ctx.acceptWebSocket(server);
		// Attachments (max 2 KB) survive hibernation; class fields don't.
		server.serializeAttachment({ username } satisfies Attachment);

		// Replay the last 20 messages to the newcomer only.
		const history = this.ctx.storage.sql
			.exec(`SELECT username, text, at FROM messages ORDER BY id DESC LIMIT 20`)
			.toArray()
			.reverse();
		server.send(JSON.stringify({ type: "history", messages: history }));

		this.broadcast({ type: "join", username, online: this.ctx.getWebSockets().length });

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

	async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
		const { username } = ws.deserializeAttachment() as Attachment;
		const at = Date.now();
		this.ctx.storage.sql.exec(
			`INSERT INTO messages (username, text, at) VALUES (?, ?, ?)`,
			username,
			String(message),
			at,
		);
		this.broadcast({ type: "message", username, text: String(message), at });
	}

	async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
		const { username } = ws.deserializeAttachment() as Attachment;
		// Don't echo `code` back: reserved codes like 1005 throw if re-sent.
		ws.close(1000, "room closed");
		const online = this.ctx.getWebSockets().filter((s) => s !== ws).length;
		this.broadcast({ type: "leave", username, online }, ws);
	}

	private broadcast(event: unknown, exclude?: WebSocket): void {
		const payload = JSON.stringify(event);
		for (const ws of this.ctx.getWebSockets()) {
			if (ws !== exclude) ws.send(payload);
		}
	}
}

The key move is this.ctx.acceptWebSocket(server) instead of the standard server.accept(): it tells the runtime to hold sockets open while the object hibernates, then wake it and invoke webSocketMessage/webSocketClose on activity. That's why usernames go in serializeAttachment — in-memory state is gone after hibernation, attachments aren't.

4. Route WebSocket upgrades in the Worker

Append the Worker entrypoint to the same src/index.ts:

export default {
	async fetch(request, env): Promise<Response> {
		const url = new URL(request.url);
		const match = url.pathname.match(/^\/rooms\/([\w-]+)$/);
		if (!match) {
			return new Response("Connect to /rooms/<name> with a WebSocket client", { status: 404 });
		}
		if (request.headers.get("Upgrade") !== "websocket") {
			return new Response("Expected a WebSocket upgrade request", { status: 426 });
		}
		// One instance per room name — every client asking for "lobby"
		// lands on the same object, wherever they connect from.
		const room = env.CHAT_ROOM.getByName(match[1]);
		return room.fetch(request);
	},
} satisfies ExportedHandler<Env>;

getByName maps a room name to exactly one Durable Object instance globally, which is what makes the state consistent without any locking on your part.

5. Run it locally

npm run dev

Wrangler prints Ready on http://localhost:8787 and emulates Durable Objects (including hibernation and SQLite) locally.

6. Deploy

npx wrangler login
npx wrangler deploy

The first command opens a browser for OAuth. Deploy output lists the CHAT_ROOM: ChatRoom binding and ends with your live URL, https://edge-chat.<your-subdomain>.workers.dev — swap ws://localhost:8787 for wss://edge-chat.<your-subdomain>.workers.dev in everything below.

Verify it works

With the dev server running, open two terminals with wscat. Terminal A:

npx wscat -c "ws://localhost:8787/rooms/lobby?username=alice"
Connected (press CTRL+C to quit)
< {"type":"history","messages":[]}
< {"type":"join","username":"alice","online":1}

Terminal B, join as bob, then type a message in A:

npx wscat -c "ws://localhost:8787/rooms/lobby?username=bob"

Terminal B sees the join, alice's message, and — after you Ctrl+C terminal A — the leave:

< {"type":"join","username":"bob","online":2}
< {"type":"message","username":"alice","text":"hello from the edge","at":1786102804765}
< {"type":"leave","username":"alice","online":1}

Now reconnect as alice: the history event replays hello from the edge from SQLite — state survived the disconnect. A plain curl http://localhost:8787/rooms/lobby returning HTTP 426 confirms the upgrade guard works.

Troubleshooting

  • create-cloudflare requires at least Node.js v22.0.0. You are using v20.19.6. — the scaffolder enforces Node 22+. Upgrade via your version manager (nvm install 22 && nvm use 22) and rerun.
  • Uncaught InvalidAccessError: Invalid WebSocket close code: 1005. — thrown in webSocketClose if you pass the incoming code back to ws.close(). 1005 means "client closed without a status code" and is reserved, so it can't be re-sent. Close with 1000 as in the code above.
  • Uncaught TypeError: Can't call WebSocket send() after close(). — your broadcast loop hit the socket that just disconnected; getWebSockets() still includes it inside webSocketClose. Exclude that socket (or wrap send in try/catch for abrupt drops).
  • In order to use Durable Objects with a free plan, you must create a namespace using a `new_sqlite_classes` migration. [code: 10097] on deploy — your migration says new_classes, which requests the paid-only key-value backend. Change it to new_sqlite_classes (new KV-backed namespaces are being phased out anyway).

Next steps

Add alarms to prune old messages on a schedule, explore the full SQLite storage API for typed queries and point-in-time recovery, and read the WebSocket best practices page for auto-response pings that keep connections alive without waking the object. When a room needs to call other services, remember public methods on your class are directly callable as RPC from any Worker with the binding.

Sources & further reading

  1. Use WebSockets - Cloudflare Durable Objects docs — developers.cloudflare.com
  2. Get started - Cloudflare Durable Objects docs — developers.cloudflare.com
  3. Durable Object migrations and class exports - Cloudflare docs — developers.cloudflare.com
  4. wrangler - npm — npmjs.com
  5. Durable Objects free plan new_sqlite_classes error 10097 - workers-sdk issue — github.com
Emeka Okafor
Written by
Emeka Okafor · Security Editor

Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading