Cloudflare Ports

proto-poker and proto-retro were built on Vercel with Upstash Redis. These ports rebuild both on Cloudflare, as an experiment in how the two platforms differ. The specific question was the Vercel versions' dependence on Upstash Redis and the limits of its free database.

The rule for the port was to change the platform and nothing else: same product, same frontend, same public HTTP API. That makes the differences that remain the result. The repos are private (eswat2/proto-poker-cf, eswat2/proto-retro-cf, and eswat2/proto-admin-cf for the dashboard).

The limits being tested

On Vercel, state and realtime both depend on a second vendor, Upstash, added through the Vercel Marketplace. Its free plan set the shape of the system:

Normal use stayed far below these limits: a reading on 2026-09-08 showed 6.4K commands and 5KB of storage. The concern was never the everyday load. It was that one misbehaving client or a third app could use up a quota two working apps shared, and that proto-draw could burn a full day's usage in one session.

What changed

Concern Vercel + Upstash Cloudflare
State JSON blob at a Redis key One Durable Object per room or board, with its own SQLite-backed storage
Concurrent writes Lua compare-and-swap, jittered retry up to 10 times None needed: a Durable Object handles one event at a time
Fan-out Redis PUBLISH, one SUBSCRIBE connection per SSE stream The writer and every reader are in the same object
Realtime SSE, reconnecting at least every 5 minutes Hibernatable WebSockets by default, SSE as a fallback
Expiry Redis key TTL, refreshed on write One Durable Object alarm, set at creation: a fixed 2-hour limit, with a 5-second countdown before the close
Rate limits Redis INCR and EXPIRE Counters in the room, plus one Limiter Durable Object per client IP
Quota Shared between both apps Each app has its own Durable Object namespaces
Tests Needs a local Redis running Vitest inside a local workerd, with nothing to start first. Workers Builds runs them before every deploy, so a failing test stops it

The design was anticipated. proto-poker's original decision log named an actor or Durable-Object-style owner as the alternative to compare-and-swap, and rejected it for Vercel because it would have meant building a queue and consumer on top. On Cloudflare that model is the platform's default, so the retry logic went away instead of being replaced. The test that fires 25 concurrent writers was kept to prove nothing is lost.

What it found

The vendor dependency goes away, but the free-plan limit doesn't. State, coordination and realtime are now all one platform, with no shared database and no second console. There's still a ceiling. It just moves, and it's a different kind of limit: metered time and rows written instead of commands.

The transport decides the cost. A direct SSE port would have been the smallest change, but an open SSE stream keeps a Durable Object resident for as long as a tab is open, at about 460 GB-s per room-hour. That made duration the first free-plan limit, at an estimated 27 one-hour sessions a day. Hibernatable WebSockets cost duration only while a message is being handled, so that limit effectively disappears and rows written becomes the ceiling, at an estimated 90 sessions a day. This is why WebSockets were added to a port meant to change nothing else.

SSE stayed anyway. Some corporate proxies block WebSocket upgrades but pass a plain streaming response, and curl -N can watch a room. The cost is per room: one SSE client keeps that room resident, while rooms with only WebSockets still hibernate.

Hibernation shapes the code. A pending timer keeps a Durable Object awake, so there are no WebSocket timers in the room. Disconnects rely on the runtime's close handling, and keepalive pings are answered without waking the object.

Some things got more hand-built. Workers have one entrypoint, so routing is a small hand-written router instead of one file per route, and per-IP rate limiting needed its own Durable Object instead of a Redis key. That Limiter writes no rows for a rejected request, so a flood can't run up the row-write ceiling.

Lifetime became one fixed rule. The first version had three idle and lifetime alarms. It now has one: a room or board closes 2 hours after it was created, whatever happens in it. The alarm is set once at creation, and writes never touch it. It fires 5 seconds early to start a countdown on both transports, and the header shows the time left. A bad or expired link gets a themed 404 page.

The browser's back/forward cache counted as a connection. Chrome can freeze a page you leave with its WebSocket still open, so the tab still counted as connected, and coming back by URL added a second one. The client now closes on pagehide and starts over on a restored pageshow, so a room that closed in the meantime shows the 404.

A closed board can still be saved. A retro board is deleted when it closes, so the closed screen offers a JSON download built in the browser from the state the tab already holds, with cards grouped by column and names in place of participant ids. Closing counts as the final reveal. Nothing changed on the server.

proto-admin

proto-admin is a private dashboard on its own eswat2.app subdomain, showing open rooms and participant counts across both apps, updated live. Building it without undoing the port's cost model shaped the design:

The ports' tickets now live in proto-admin-cf, which serves as the home for the whole suite.

eswat2.app

All three — poker, retro and the admin dashboard — run on one Cloudflare domain, which is itself a small comparison point. Each app is its own Worker on a subdomain, poker.eswat2.app and retro.eswat2.app, plus the private dashboard, and the root is a static index page served from Workers static assets with no Worker code. It links to the live apps, their about and architecture pages, and this knowledge base. Wrangler creates the DNS records and certificates, and pushes to main deploy through Workers Builds.

The domain also names one app it doesn't run. Since November 2026 this knowledge base is served at mcp.eswat2.app while staying on Vercel: a DNS-only CNAME in the Cloudflare zone points at Vercel, which issues the certificate itself. Proxying it through Cloudflare would stack two CDNs and two TLS layers in front of the same app. Where an app runs and what it's called turned out to be separate decisions, so moving a host later becomes a DNS change rather than a new URL. The original eswat2-mcp.vercel.app address keeps serving alongside it, and not as a redirect, because some MCP clients don't re-send a POST after one.

Linking the live apps is safe here in a way it isn't for the Vercel versions, whose URLs proto-realtime leaves out to protect the free tier. Creating a room or board needs a creator key, joining takes only a link, and static pages never touch a Durable Object, so casual crawler traffic doesn't reach the limits.

The session estimates and GB-s figure come from the ports' architecture decision logs, and the Upstash figures from the Vercel versions' project notes. Both are as measured at the time and may not match current vendor plans.