Cloudflare Ports
- » live: eswat2.app
- » info: proto-poker · architecture
- » info: proto-retro · architecture
- » private: proto-admin, behind Cloudflare Access
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:
- One free database per account. proto-retro had to share proto-poker's database to stay at $0, with namespaced keys (
board:*androom:*). That meant shared fate: heavy traffic on one app could use up the other's quota. - 500K commands a month and 256MB of storage, across both apps.
- Every action costs commands, including abuse. A sweep of the original 230,400-value room id space would have been about 230K commands, nearly half the month. Scanner protection had to be designed around the quota.
- Usage shows only in Upstash's own console, not in Vercel's.
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:
- No public admin routes. The dashboard Worker reaches each app through a service binding to an
AdminApientrypoint, so the apps' public HTTP API is unchanged. - A load wakes no rooms. Each room reports its counts to a Registry Durable Object in its app when someone joins, connects or leaves, and when it closes. A vote sends nothing. Reports carry the full counts and a timestamp, so a lost one fixes itself on the next report.
- Live without polling. The page holds one hibernatable WebSocket per app, opened to the Registry through the same service binding. The Registry sends the whole scoreboard on connect and after every change. If the sockets can't connect, the page falls back to loading when you come back to its tab.
- Recount, on demand only. Recount asks every open room directly and corrects the scoreboard. It wakes every room, so it runs only when clicked, and its results are shared for 5 seconds.
- Locked by default. Cloudflare Access protects the domain, and the Worker checks the Access token on every request as well. If any of its Access settings are missing, every request gets a 403. The
workers.devand preview URLs are off, because they would skip Access. A socket is checked only when it opens, so the Registry is passed the token's expiry and closes the socket at the next change after that.
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.