<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arjun</title>
    <description>The latest articles on DEV Community by Arjun (@arjunbear).</description>
    <link>https://dev.to/arjunbear</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3984846%2Fe2a02b87-5e42-4f9b-b386-1f6d29561397.jpg</url>
      <title>DEV Community: Arjun</title>
      <link>https://dev.to/arjunbear</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arjunbear"/>
    <language>en</language>
    <item>
      <title>I built a real-time stranger-chat app on Cloudflare Durable Objects (no servers, no Redis)</title>
      <dc:creator>Arjun</dc:creator>
      <pubDate>Mon, 15 Jun 2026 06:46:10 +0000</pubDate>
      <link>https://dev.to/arjunbear/i-built-a-real-time-stranger-chat-app-on-cloudflare-durable-objects-no-servers-no-redis-428o</link>
      <guid>https://dev.to/arjunbear/i-built-a-real-time-stranger-chat-app-on-cloudflare-durable-objects-no-servers-no-redis-428o</guid>
      <description>&lt;p&gt;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 &lt;a href="https://chatarooni.com" rel="noopener noreferrer"&gt;chatarooni.com&lt;/a&gt; (no signup, just open it).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Durable Objects are a stateful actor at the edge
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So instead of a server + Redis, I have three kinds of DO.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of DO
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session DO&lt;/strong&gt; - 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversation DO&lt;/strong&gt; - 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).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matchmaker DO&lt;/strong&gt; - 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.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  Games are pure reducers, shared by client and server
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;GameDef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;players&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;over&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;winnerPublicId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;turnPublicId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State is plain JSON. The Conversation DO runs &lt;code&gt;apply&lt;/code&gt; as the &lt;strong&gt;referee&lt;/strong&gt; (every move is validated server-side; an illegal move is rejected). The web client runs the &lt;strong&gt;same&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  War story: getting under the 3 MiB worker limit
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

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

&lt;p&gt;Lesson: on the edge, bundle size is a first-class constraint, not an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few smaller wins
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth is its own Worker.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversations self-destruct.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geolocation is free.&lt;/strong&gt; Every Worker request carries &lt;code&gt;request.cf&lt;/code&gt; (country, city, ASN), so there's no IP-lookup API to call or pay for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want to poke at it, it's live (no signup) at &lt;a href="https://chatarooni.com" rel="noopener noreferrer"&gt;chatarooni.com&lt;/a&gt;: random strangers, in-chat games, and synced YouTube. Happy to answer architecture questions in the comments.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
