TL;DR: Cloudflare Workers + Durable Objects give you stateful WebSocket rooms at the edge. FluxyChat packages the stack — room fan-out, reconnect, history pagination, agent streaming — so you can ship production-grade chat in hours, not weeks.
The problem with "just use WebSockets"
REST on Cloudflare Workers is trivial. WebSockets are not — at least not the stateful parts.
Workers are stateless by design. Each HTTP request hits a fresh isolate. That's great for HTTP, but a WebSocket room needs a single coordination point: something that holds open connections, fans out messages in order, and survives reconnects without losing state.
The naive solution is to run a persistent Node.js/Go process on a VPS. That works, but now you've got a socket fleet to manage, a single region, and an ops burden you didn't plan for.
Durable Objects solve this. One DO per room = one persistent object on Cloudflare's edge that handles WebSocket connections for that room and fans out messages with ordering you control in code.
The architecture
Browser client
│
▼
Cloudflare Worker (HTTP + WebSocket upgrade)
│
▼
Durable Object: RoomDurableObject (one per room)
├── Holds open WebSocket connections
├── Fans out messages to clients
└── Persists messages to D1 (SQLite at the edge)
Key properties:
- No VPS, no socket fleet — the DO is your coordination layer
- Edge placement — Cloudflare routes the DO near your users
- D1 for persistence — history survives Worker restarts
- One DO per room — isolation between rooms
Option A: Build it yourself (the hard parts)
If you want to roll your own, here's the shape of what you'll implement.
Note: The snippets below are simplified for teaching. Production FluxyChat adds JWT auth, room membership checks, D1 writes, quotas, SSE/polling fallback, agent events, and an operator console.
1. WebSocket upgrade in the Worker
FluxyChat routes room WebSockets at /ws/room/:roomId (not /rooms/:id/ws). The Worker verifies the JWT and membership, then forwards to the DO stub:
// Simplified Worker routing
if (url.pathname.startsWith("/ws/room/")) {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 400 });
}
const roomId = url.pathname.split("/").pop()!;
// 1. Verify JWT (Authorization header or ?token=)
// 2. Ensure user is a member of roomId in D1
const id = env.ROOM.idFromName(roomId);
return env.ROOM.get(id).fetch(request);
}
2. The Room Durable Object — fan-out
In FluxyChat the class is RoomDurableObject (apps/worker/src/durable-objects/room-do.js). The upgrade path uses WebSocketPair, then webSocket.accept() on the server side:
export class RoomDurableObject {
constructor(state, env) {
this.state = state;
this.env = env;
this.clients = new Set();
}
async fetch(request) {
if (request.headers.get("Upgrade") === "websocket") {
const [client, server] = Object.values(new WebSocketPair());
await this.handleWebSocket(server, request);
return new Response(null, { status: 101, webSocket: client });
}
return new Response("Not found", { status: 404 });
}
async handleWebSocket(webSocket, request) {
webSocket.accept();
// Auth + membership checks, then:
this.clients.add(webSocket);
webSocket.addEventListener("message", async (event) => {
const msg = JSON.parse(event.data as string);
await this.broadcast({ ...msg, id: crypto.randomUUID(), createdAt: new Date().toISOString() });
// Production: INSERT into D1, webhooks, agent hooks, etc.
});
webSocket.addEventListener("close", () => {
this.clients.delete(webSocket);
});
}
async broadcast(message) {
const payload = JSON.stringify(message);
for (const ws of this.clients) {
try {
ws.send(payload);
} catch {
this.clients.delete(ws);
}
}
}
}
3. What you still need to implement yourself
Broadcasting gets you a demo. Production usually needs:
- Reconnect + backoff on the client
-
History pagination (
beforecursor) for reconnect/replay - Auth / JWT scoped per tenant/project
- Multi-tenancy and quotas
- Presence and typing
- Agent/LLM events on the same timeline
- Operator tooling (rooms, keys, webhooks)
Each item is easy to underestimate.
Option B: Use FluxyChat (same stack, already integrated)
FluxyChat is MIT-licensed chat infrastructure on Cloudflare Workers + Durable Objects + D1.
It ships:
-
One
RoomDurableObjectper room — fan-out without a socket fleet -
@fluxy-chat/sdk—useChat({ roomId, client }), reconnect/backoff,loadMore(), per-message delivery status - Multi-tenant — JWT scoped by project
-
Agent streaming —
tool_call,tool_result,agentRunon the same room WebSocket - Operator console — rooms, agents, webhooks, billing hooks (Next.js dashboard)
- MIT self-host on your Cloudflare account, or hosted beta
Try without signup: Guest demo room (when the operator enables DEMO_ENABLED on the Worker).
Getting started in ~5 minutes (hosted beta)
- Sign up + onboarding → project + API key flow
- Install the SDK:
npm install @fluxy-chat/sdk
- Mint a member JWT server-side (never ship
fc_...keys to the browser):
curl -X POST "https://api.fluxychat.com/auth/token" \
-H "Content-Type: application/json" \
-H "X-Fluxy-Api-Key: fc_your_project_key" \
-d '{"userId":"alice","roles":["member"],"ttlSeconds":3600}'
Env URLs:
- Hosted cloud:
NEXT_PUBLIC_FLUXYCHAT_CLOUD_URL=https://api.fluxychat.com - Self-host:
NEXT_PUBLIC_FLUXYCHAT_WORKER_URL=https://your-worker.example.com
React component
import { FluxyChatClient, useChat } from "@fluxy-chat/sdk";
const client = new FluxyChatClient({
baseUrl: process.env.NEXT_PUBLIC_FLUXYCHAT_CLOUD_URL!, // or WORKER_URL when self-hosting
userId: "alice",
token: memberJwtFromYourBackend,
});
function Room({ roomId }: { roomId: string }) {
const {
messages,
sendMessage,
connectionState,
connectionStatus,
loadMore,
hasMore,
} = useChat({ roomId, client });
return (
<div>
{hasMore && (
<button type="button" onClick={() => void loadMore()}>
Load older messages
</button>
)}
<p className="text-xs text-muted-foreground">
{connectionState.status}
{connectionState.nextRetryAt
? ` · retry at ${connectionState.nextRetryAt}`
: null}
</p>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.userId}</strong>: {m.content}
{m.deliveryStatus ? (
<span className="text-xs"> ({m.deliveryStatus})</span>
) : null}
</div>
))}
<input
onKeyDown={(e) => {
if (e.key === "Enter") {
sendMessage(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
placeholder="Send a message…"
/>
</div>
);
}
Connection state (reconnect UX)
const { connectionState, connectionStatus } = useChat({ roomId, client });
// connectionState.status → 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | …
// connectionState.nextRetryAt → ISO timestamp for “Reconnecting in 3s…”
// connectionState.transport → 'websocket' | 'sse' | 'polling' | 'none'
// connectionStatus → shorthand alias used in older examples
When WebSockets are blocked, the SDK can fall back to SSE or polling — see the transport fallback cookbook.
Self-host on your Cloudflare account
git clone https://github.com/AlessandroFare/fluxychat
cd fluxychat
pnpm install
cp apps/worker/.dev.vars.example apps/worker/.dev.vars
# ALLOWED_ORIGINS, optional AI keys, DEMO_* — see the example file
# Local dev
pnpm dev
# First-time remote D1 migrations, then deploy
cd apps/worker
pnpm exec wrangler d1 migrations apply fluxychat --remote
pnpm run deploy
Handling the "do I need a VPS?" question
Short answer: no, if you use Workers + Durable Objects for connection state.
A Durable Object holds WebSocket connections, fans out updates, and can persist to D1. You don't run a long-lived VM for sockets.
For a stock ticker, IoT feed, or live dashboard on static hosting:
Data source → HTTP POST to Worker → Room DO broadcasts to all connected clients
No VPS, no Redis pub/sub fleet on a VM. The DO is your pub/sub layer for that room.
Further reading
- FluxyChat GitHub — MIT monorepo
- Landing + overview
- Guest demo room
- Compare vs Stream / Ably / Pusher-style vendors
- Transport fallback cookbook
- Bot/agent streaming cookbook
- Cloudflare Durable Objects docs
Disclosure: I'm the author of FluxyChat. Option A is the mental model; Option B is the repo I maintain — use whichever fits your project.
Top comments (0)