<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Websockets on Omegion</title><link>https://omegion.dev/tags/websockets/</link><description>Recent content in Websockets on Omegion</description><generator>Hugo</generator><language>en-us</language><copyright>Omegion</copyright><lastBuildDate>Wed, 26 Aug 2026 13:19:47 +0200</lastBuildDate><atom:link href="https://omegion.dev/tags/websockets/index.xml" rel="self" type="application/rss+xml"/><item><title>Scrummy: From Redis and Laravel to Cloudflare Durable Objects</title><link>https://omegion.dev/2026/03/scrummy-from-redis-and-laravel-to-cloudflare-durable-objects/</link><pubDate>Thu, 05 Mar 2026 00:00:00 +0000</pubDate><guid>https://omegion.dev/2026/03/scrummy-from-redis-and-laravel-to-cloudflare-durable-objects/</guid><description>Why I moved Scrummy&amp;rsquo;s planning poker backend off a Laravel app and a Redis-backed websocket server, and rebuilt it as one Cloudflare Durable Object per room.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>My team used to run sprint planning with a couple of the existing planning
poker tools out there. None of them were bad, exactly, they were just built
for someone else&rsquo;s team: too many settings we didn&rsquo;t need, too few of the
ones we did. At some point that turns into an itch, and I built
<a href="https://scrummy.dev" target="_blank" rel="noopener noreferrer">Scrummy</a>
 to scratch it, a free planning poker room
with no accounts and no setup, tuned to how my own team actually plays.
<a href="https://github.com/scrummmy/scrummy" target="_blank" rel="noopener noreferrer">The code</a>
 is open source, if you
want more than the snippets below.</p>
<p>The first version worked. It also ran on more infrastructure than a &ldquo;type
in a URL, vote on a card, see the average&rdquo; app should need.</p>
<h2 id="the-first-version-was-overkill">The first version was overkill</h2>
<p>Scrummy started as a Laravel app with a Postgres database, real user
accounts, and teams you&rsquo;d invite people to, plus a separate Redis-backed
websocket server pushing votes around a room in real time. I&rsquo;d built it as
sign-up-and-invite software, then realized nobody wants to create an
account just to vote on a story point with their team. So I dropped the
whole auth and teams layer: no accounts, just a nickname and a room link,
and anyone who has the link can join.</p>
<p>That decision made the rest obvious. If nothing needs to persist past a
room&rsquo;s lifetime, the app doesn&rsquo;t need a traditional server-plus-database
stack running underneath it either.</p>
<h2 id="one-durable-object-per-room">One Durable Object per room</h2>
<p>The rebuild replaced all of it with Cloudflare Workers and a single
<a href="https://developers.cloudflare.com/durable-objects/" target="_blank" rel="noopener noreferrer">Durable Object</a>
 per
room.</p>
<p>If you haven&rsquo;t used one before, picture a Durable Object as a single
storage bucket that lives in one specific place on Cloudflare&rsquo;s network,
not copied across every point of presence (PoP) the way a normal Worker
request is. A regular Worker runs wherever the request happens to land,
closest to whoever&rsquo;s asking, and remembers nothing between requests. A
Durable Object is the opposite: you give it an ID, and every request for
that ID gets routed to that same one instance, wherever it actually lives,
so it can hold state in memory and handle requests one at a time instead
of two copies of itself stepping on each other.</p>
<p>For Scrummy, that ID is the room&rsquo;s slug. A Worker routes an incoming
WebSocket request to the Durable Object for that slug:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">typescript</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">id</span> <span class="o">=</span> <span class="nx">env</span><span class="p">.</span><span class="nx">ROOM</span><span class="p">.</span><span class="nx">idFromName</span><span class="p">(</span><span class="nx">slug</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">stub</span> <span class="o">=</span> <span class="nx">env</span><span class="p">.</span><span class="nx">ROOM</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="nx">id</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="nx">stub</span><span class="p">.</span><span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span><span class="p">);</span></span></span></code></pre></div>
</div>
<p><code>idFromName</code> derives that ID from the slug itself, so the routing needs no
lookup table of its own; the same slug always maps to the same object.
There&rsquo;s no separate process to keep alive between rooms, and no shared
database row two rooms could collide on. Each room is its own small,
isolated unit of state.</p>
<p>Wiring it up is a few lines in <code>wrangler.jsonc</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">jsonc</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jsonc" data-lang="jsonc"><span class="line"><span class="cl"><span class="s2">&#34;durable_objects&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bindings&#34;</span><span class="p">:</span> <span class="p">[{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;ROOM&#34;</span><span class="p">,</span> <span class="nt">&#34;class_name&#34;</span><span class="p">:</span> <span class="s2">&#34;Room&#34;</span> <span class="p">}]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;migrations&#34;</span><span class="err">:</span> <span class="p">[{</span> <span class="nt">&#34;tag&#34;</span><span class="p">:</span> <span class="s2">&#34;v1&#34;</span><span class="p">,</span> <span class="nt">&#34;new_sqlite_classes&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;Room&#34;</span><span class="p">]</span> <span class="p">}]</span></span></span></code></pre></div>
</div>
<h2 id="websockets-that-dont-cost-anything-while-idle">Websockets that don&rsquo;t cost anything while idle</h2>
<p>The part that replaced the Redis server is the
<a href="https://developers.cloudflare.com/durable-objects/best-practices/websockets/" target="_blank" rel="noopener noreferrer">WebSocket Hibernation API</a>
.
A Durable Object can accept a WebSocket connection and then hibernate
between messages, holding no memory or compute open, and wake back up when
one arrives:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">typescript</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span>: <span class="kt">Request</span><span class="p">)</span><span class="o">:</span> <span class="nx">Promise</span><span class="p">&lt;</span><span class="nt">Response</span><span class="p">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">pair</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WebSocketPair</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="p">[</span><span class="nx">client</span><span class="p">,</span> <span class="nx">server</span><span class="p">]</span> <span class="o">=</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">values</span><span class="p">(</span><span class="nx">pair</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">acceptWebSocket</span><span class="p">(</span><span class="nx">server</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span>: <span class="kt">101</span><span class="p">,</span> <span class="nx">webSocket</span>: <span class="kt">client</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>State that needs to survive hibernation goes into the Durable Object&rsquo;s own
SQLite storage, and each socket carries its user&rsquo;s identity with
<code>ws.serializeAttachment</code>, so a room that wakes back up after sitting idle
for a day still knows who&rsquo;s who without a database lookup. A 30-day alarm
handles cleanup: if nobody&rsquo;s visited a room in a month, the object deletes
its own storage and disappears.</p>
<p>The full <code>Room</code> object, including how it handles joins, votes, and a
short reconnect grace period for someone who just refreshed the page, is
in <a href="https://github.com/scrummmy/scrummy/blob/master/worker/room.ts" target="_blank" rel="noopener noreferrer"><code>worker/room.ts</code></a>

on GitHub.</p>
<h2 id="why-not-just-a-database">Why not just a database</h2>
<p>A database would work here too, it&rsquo;s just the wrong tool for this. The
thing Scrummy needs on every vote is simple: tell everyone else in the
room. The old stack meant a separate Node process fanning out socket
events, with Redis and Postgres nearby for everything else. With a
Durable Object, the Worker already holds every open socket for that room
in memory, so broadcasting a vote is a loop over connections it already
has, not a hop through another service:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">typescript</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">private</span> <span class="kr">async</span> <span class="nx">broadcast</span><span class="p">(</span><span class="nx">message</span>: <span class="kt">ServerMessage</span><span class="p">)</span><span class="o">:</span> <span class="nx">Promise</span><span class="p">&lt;</span><span class="nt">void</span><span class="p">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">payload</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">ws</span> <span class="k">of</span> <span class="k">this</span><span class="p">.</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">getWebSockets</span><span class="p">())</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">ws</span><span class="p">.</span><span class="nx">readyState</span> <span class="o">===</span> <span class="nx">WebSocket</span><span class="p">.</span><span class="nx">OPEN</span><span class="p">)</span> <span class="nx">ws</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">payload</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>Storage only comes into it to survive hibernation, a restart, or the
Durable Object migrating between machines. Coordinating the people
in the room doesn&rsquo;t touch it at all.</p>
<h2 id="conclusion">Conclusion</h2>
<p>I wouldn&rsquo;t reach for a Durable Object for most of what Redis is good at.
But for state scoped to a small group of people, that lives for a few
days and needs to reach everyone watching the instant it changes, one
object per room turned out to be a better match than a server and a
database ever were, and it&rsquo;s one less thing I have to keep running.</p>
<p>Try it yourself at <a href="https://scrummy.dev" target="_blank" rel="noopener noreferrer">scrummy.dev</a>
. If something&rsquo;s
missing or you&rsquo;d build a piece of it differently, open an issue or a PR
on <a href="https://github.com/scrummmy/scrummy" target="_blank" rel="noopener noreferrer">GitHub</a>
.</p>
]]></content:encoded></item></channel></rss>