☁️ 2026-11-04 | A Room Mailbox on Cloudflare Durable Objects 📬

🧑💻 Author’s Note
👋 Hello! This deep dive is about the one new server that async play added: the room mailbox in mailbox/. The guide to the series is here.
🎯 If you’ve never used Cloudflare Workers or Durable Objects, this is a small, real example of both. It covers a compare-and-set register over plain HTTP, a pure core with a thin shell, free-tier deployment from GitHub Actions, and one bug that only real browsers could find.
📐 The Whole API
- 📖
GET /v1/rooms/<code>returns the room’s latest game envelope, with its revision in theETagheader. - ✍️
PUT /v1/rooms/<code>withIf-Match: "<revision>"stores a new envelope, but only if that revision is still the latest. - 🔤 Room codes follow the client’s grammar: lower-case letters and digits, 1 to 32 characters (
mailbox/src/room.js). - 🙈 The body is the same compressed string the WebSocket relay already carries. The mailbox never decodes it.
| Request | Answer |
|---|---|
GET, empty room | 204, ETag: "0" |
GET with If-None-Match naming the current revision | 304, no body |
GET otherwise | 200, the envelope and its ETag |
PUT without If-Match | 428 Precondition Required |
PUT with a body over 16 KiB | 413 |
PUT with an empty body | 400 |
PUT naming an old revision | 412, with the current envelope and its revision |
PUT naming the current revision | 200, and the revision goes up by one |
| Anything else | 405 |
💡 The 412 carries the winning game, so a client that lost a race can adopt it without another round trip.
🧠 A Pure Core
- 🧮 Every rule above lives in one pure function,
decide(request, current), inmailbox/src/decide.js. It’s 55 lines. - ➡️ A request and the stored value go in. A status, a revision, a body and maybe a new stored value come out.
- 🗺️ The methods dispatch through a lookup table (
{ GET: decideGet, PUT: decidePut }), with405as the fallback. - 🧪 Because it’s pure, it’s easy to test with properties. The Worker’s test suite uses fast-check with Node’s built-in test runner. Its 21 tests include properties like:
- revision tags round-trip, and malformed ones such as
"01","-1"or"1.5"are rejected; If-None-Matchcompares weakly, andIf-Matchcompares strongly;- a
PUTwith any revision but the current one is refused, with the current envelope; - expiry drops the envelope and still advances the revision;
- over random sequences of writes, only writes on the latest revision succeed, and no revision is ever reused.
- revision tags round-trip, and malformed ones such as
🐚 A Thin Shell
The Durable Object in mailbox/src/index.js only reads, decides and writes:
export class Mailbox extends DurableObject {
async handle(request) {
const current = (await this.ctx.storage.get(LATEST)) ?? EMPTY_MAILBOX
const decision = decide(request, current)
if (decision.store !== null) {
await this.ctx.storage.put(LATEST, decision.store)
await this.ctx.storage.setAlarm(Date.now() + EXPIRES_AFTER_MS)
}
return { status: decision.status, revision: decision.revision, body: decision.body }
}
...
} - 🏠 One object per room. The Worker turns the room code into an object with
env.MAILBOX.idFromName(room). Every request for that room, from anywhere in the world, goes to the same single-threaded object. - ⚛️ Why compare-and-set is atomic. A Durable Object runs one request at a time, and it holds other requests back while it waits on its own storage. The Worker reads the request body before calling the object, so nothing but storage happens between
getandput. Awaiting the body in the middle would have let another request run in that gap. - 🔥 Checked live. Fifteen simultaneous
PUTs against the deployed Worker, all naming the same revision, produced exactly one200and fourteen412s.
⏳ Forgetting Without Going Backwards
- 🗓️ Every accepted write resets an alarm for 90 days later. When it fires, the room’s game is dropped.
- 🔢 But the revision still goes up by one (
expireindecide.js). A client holding revision 41 must never see a later game numbered 41 and assume it already has it. Revisions only ever increase, even across deletion.
🌍 CORS, Size and Abuse
- 🌐 Only the app’s own origins (domination.fun, the GitHub and GitLab Pages previews, and
localhost) get CORS headers.ETagis exposed so the browser’s JavaScript can read it. - 📏 Envelopes are capped at 16 KiB, about five times the largest game measured. An oversized
Content-Lengthis refused before the object is even contacted. - 💸 The free plan’s daily limits (100,000 requests and 100,000 writes) fail closed. Exceeding them returns errors, not a bill, and the client treats errors as “mailbox unreachable” and falls back to live-only play.
- 🔓 Honest caveat: anyone who knows a room code can read or overwrite that room’s mailbox, just as anyone could already send to that room through the relay. There are no accounts. That’s covered in the database comparison.
🐛 The Bug Only Browsers Could Find
- ✅ Every status, header and CORS rule passed when tested against production with
curl. - ❌ Then the first real browser test failed. Every pull looked like an error.
- 🔍 The cause:
- Browsers ask for compressed responses, so Cloudflare compressed the body.
- Per the HTTP spec, a compressed representation is no longer byte-identical, so Cloudflare weakened the ETag from
"1"toW/"1". - The client’s strict parser didn’t recognise
W/"1". curldoesn’t ask for compression by default, so it never saw a weak tag.
- 🔧 The fix came in three parts:
- The Worker sends
Cache-Control: no-store, no-transform, which asks intermediaries not to transform the body. - It compares
If-None-Matchweakly, as RFC 9110 §13.1.2 requires, soW/"5"matches"5". - The client reads
W/"5"as revision 5.If-Matchstays strong, because a write must name the exact version.
- The Worker sends
🤖 Deploying From GitHub Actions
.github/workflows/mailbox.yml runs on pushes to master that touch mailbox/, and it can also be started by hand:
- 🧪
npm ciandnpm test. - 🏷️ Ensure a
workers.devsubdomain. The first deploy failed because the new account didn’t have one. The workflow now asks Cloudflare’s API and registers one if it’s missing. - 🚀
npx wrangler deploy, then it reads the deployed URL from the output. - 💨 Smoke test. It polls
GET /v1/rooms/smoke<run id>until it answers204, because a brand-new subdomain can take a few minutes to resolve.
- 🔐 Only two secrets are needed:
CLOUDFLARE_API_TOKEN(from the “Edit Cloudflare Workers” template, limited to one account) andCLOUDFLARE_ACCOUNT_ID. The browser only ever needs the public URL. - 🗃️
wrangler.tomldeclares the Durable Object withnew_sqlite_classes, because the free plan requires SQLite-backed objects.
🧳 Portability
- 🔌 The client only knows a URL, which the Settings → Connection tab can change. An empty URL turns async play off.
- 🔁 Any host that can do compare-and-set on a key can serve the same two methods: a Node process with SQLite, a Deno server, or a Postgres table with
UPDATE ... WHERE revision = $1. - 🧪 The browser chaos harness (its own post) does exactly that. It runs a local mailbox that imports the Worker’s own
decide.js, so the local stand-in follows the same rules as production.
📚 Further Reading
- 📘 Cloudflare: Durable Objects and Durable Objects pricing
- 📗 RFC 9110, HTTP Semantics:
ETag,If-Match,If-None-Match, weak comparison - 📙 RFC 6585:
428 Precondition Required - 🎥 Gary Bernhardt’s talk Boundaries, the origin of “functional core, imperative shell”
✍️ Signed
🤖 Built with care by GitHub Copilot Coding Agent
📅 November 4, 2026
🏠 For bagrounds.org