VOOZH about

URL: https://dev.to/arjunbear/i-built-a-real-time-stranger-chat-app-on-cloudflare-durable-objects-no-servers-no-redis-428o

⇱ I built a real-time stranger-chat app on Cloudflare Durable Objects (no servers, no Redis) - DEV Community


A few months ago I set out to rebuild the old Omegle idea (match two strangers, let them chat, play a game, watch a YouTube video in sync) with one hard constraint: no always-on servers, no Redis, no WebSocket box to babysit. The whole thing runs on Cloudflare's edge. It's live at chatarooni.com (no signup, just open it).

Real-time chat usually means a stateful WebSocket server plus a shared store (Redis) for presence and matchmaking. That's exactly the part I wanted to delete. Here's the architecture I landed on.

Durable Objects are a stateful actor at the edge

A Cloudflare Durable Object is a single, globally-addressable instance with its own storage that processes requests one at a time. That single-threaded, "one authoritative place" property is awkward for a lot of web work but perfect for the two things a chat app actually needs an authority for: a chat room, and a matchmaking queue.

So instead of a server + Redis, I have three kinds of DO.

Three kinds of DO

  • Session DO - one per connected user. Holds the live WebSocket, tracks presence, and runs a short grace timer so a refresh or tab-blip doesn't instantly mark you offline.
  • Conversation DO - one per chat. The authoritative room: it stores messages in the DO's embedded SQLite, relays between the two participants, and acts as referee for any shared activity (games, the synced video).
  • Matchmaker DO - a single queue. Clients ask to be matched; it pairs them (honoring gender preference), spins up a Conversation DO, and hands both sides its ID.

No external database sits in the hot path. Presence, matchmaking, and message relay are all just DOs talking to DOs over their IDs.

Games are pure reducers, shared by client and server

The chat has turn-based games (tic-tac-toe, chess). I didn't want game logic living in two places, so a game is a pure reducer:

interface GameDef<S, A> {
 id: string;
 init(players: string[]): S;
 apply(state: S, action: A, by: string): { ok: true; state: S } | { ok: false; error: string };
 status(state: S): { over: boolean; winnerPublicId: string | null; turnPublicId: string | null };
}

State is plain JSON. The Conversation DO runs apply as the referee (every move is validated server-side; an illegal move is rejected). The web client runs the same reducer for instant optimistic UI, then reconciles when the authoritative snapshot lands. Adding a new game is literally: write a reducer, register it. Chess is backed by chess.js, and its state is just the SAN move list replayed each call, which keeps threefold-repetition and the 50-move rule working for free.

War story: getting under the 3 MiB worker limit

The frontend is Next.js 16 (App Router) deployed to Workers via OpenNext. The SSR worker ballooned to about 5 MiB. The Cloudflare free plan caps a worker at 3 MiB compressed. Two changes got it to 1.94 MiB:

  1. Build with webpack, not Turbopack. Turbopack emitted roughly 2x larger per-route client-reference-manifests, and OpenNext bundles those into the worker.
  2. Don't enable experimental.inlineCss. It inlines the stylesheet into every route's RSC payload, and with ~30 prerendered pages that alone added over a megabyte gzipped.

Lesson: on the edge, bundle size is a first-class constraint, not an afterthought.

A few smaller wins

  • Auth is its own Worker. Better Auth issues JWTs (JWKS-verified by the other services), and it's anonymous-first: you start chatting instantly, then optionally "claim" the account by linking Google/Facebook. Its DB is D1 with read replication, so session reads hit a nearby replica.
  • Conversations self-destruct. A stranger chat wipes itself 7 days after its last message via a lazy DO alarm: the alarm re-computes its deadline only when it fires, so sending a message never pays the write cost of sliding a timer.
  • Geolocation is free. Every Worker request carries request.cf (country, city, ASN), so there's no IP-lookup API to call or pay for.

Result

A globally-distributed real-time app with no servers to run and a near-zero idle cost. The entire realtime tier is Durable Objects; the only database is D1 for auth.

If you want to poke at it, it's live (no signup) at chatarooni.com: random strangers, in-chat games, and synced YouTube. Happy to answer architecture questions in the comments.