DEV Community

Cover image for How to Build a Realtime Chat App on Cloudflare Workers (Without Managing a Socket Fleet)
Fluxychat SDK
Fluxychat SDK

Posted on

How to Build a Realtime Chat App on Cloudflare Workers (Without Managing a Socket Fleet)

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)
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

3. What you still need to implement yourself

Broadcasting gets you a demo. Production usually needs:

  • Reconnect + backoff on the client
  • History pagination (before cursor) 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 RoomDurableObject per room — fan-out without a socket fleet
  • @fluxy-chat/sdkuseChat({ roomId, client }), reconnect/backoff, loadMore(), per-message delivery status
  • Multi-tenant — JWT scoped by project
  • Agent streamingtool_call, tool_result, agentRun on 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)

  1. Sign up + onboarding → project + API key flow
  2. Install the SDK:
npm install @fluxy-chat/sdk
Enter fullscreen mode Exit fullscreen mode
  1. 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}'
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

No VPS, no Redis pub/sub fleet on a VM. The DO is your pub/sub layer for that room.


Further reading


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)