I wanted a channel that worked like the ones in the stories I like: you show up, you’re there, you leave, and it’s like you were never there. No account. No history. No paper trail.
This is a writeup of how I built that. The technical name for what I built is an anonymous ephemeral WebSocket chat room. I’m calling it the beacon.
The constraint that shaped everything
The site runs on Cloudflare Pages — a static/edge hosting platform with no persistent server. Normally, real-time chat requires a server that stays alive between requests, holding open connections for every user simultaneously.
Cloudflare doesn’t give you that. What it gives you instead is something more interesting.
Durable Objects: stateful edge computing
A Durable Object is a small, single-threaded JavaScript object that lives at the edge — in a Cloudflare datacenter near your users. It has two properties that make it unusual:
1. It has memory. Regular serverless functions are stateless — each request starts fresh. A Durable Object persists in memory between requests, as long as it’s being used. You can store things in it.
2. It can hold WebSocket connections. This is the key piece. A Durable Object can accept WebSocket upgrades and then act as the broker between every connected client. When you send a message, it receives it and forwards it to everyone else connected to the same object.
The architecture is:
Browser A ──┐
Browser B ──┤──→ /api/beacon ──→ BeaconRoom (Durable Object)
Browser C ──┘
All three browsers connect to the same Durable Object. When A sends a message, the DO receives it and sends it to B and C. Real-time. No separate message queue, no database write, no polling.
Why ephemeral
I made a deliberate choice to not persist messages.
When the last person leaves and the Durable Object hibernates (more on that below), the message history is gone. The next person who connects gets a blank room.
This is a feature, not a limitation. The beacon isn’t an archive — it’s a moment. If you were there, you were there. If you weren’t, there’s nothing to catch up on.
It also simplifies the trust model considerably. There’s no database to breach because there’s no database. The worst an attacker can do is intercept messages in transit (which TLS handles) or sit in the room (which is public anyway).
WebSocket Hibernation
Here’s the expensive problem with WebSockets: each open connection keeps the Durable Object alive in memory. If nobody is talking but 10 people have the tab open, you’re paying for a running process doing nothing.
Cloudflare’s Hibernation API solves this. When no events are occurring, the DO is evicted from memory. The WebSocket connections remain open from the client’s perspective — Cloudflare maintains them at the network level — but the server-side code isn’t running.
When a message arrives, the DO wakes up, handles it, broadcasts to connected clients, then goes back to sleep.
This means the beacon costs almost nothing when it’s idle.
The catch: in-memory state is reset on each wake. Any state you need to survive hibernation has to be serialized per-connection via serializeAttachment / deserializeAttachment. For the beacon, the only per-connection state is the anonymous ID and a rate-limit timestamp — both small enough to attach to the WebSocket itself.
Anonymous identity
Every connection gets a random 4-digit hex ID — 0x3f8a, 0xb1c2, that kind of thing. It’s generated server-side when you connect and attached to your WebSocket session.
You don’t choose it. You don’t register for it. It disappears when you disconnect.
There are no cookies. No fingerprinting. No IP logging. The server never sees your IP directly — Cloudflare proxies everything. Your hex ID is your identity for the duration of the session and nothing else.
There’s a meaningful difference between anonymity and pseudonymity. A pseudonym is consistent across interactions — people can build a reputation or a pattern. The beacon’s hex IDs are neither: they’re random, session-scoped, and not linked to anything. It’s closer to anonymity than pseudonymity, which is what I wanted.
Moderation: v1
The hardest problem with anonymous public chat isn’t the technical architecture. It’s keeping it from immediately becoming unusable.
The v1 approach is a server-side content filter: a set of regex patterns that match known spam patterns, repeated-character abuse, and a few other things. When a message matches, it’s silently dropped — the sender gets a private [ERR] message blocked notice, other users see nothing.
This is crude. It’ll miss things and occasionally block things it shouldn’t.
The v2 I actually want to build is ML-based: run messages through a classifier trained on toxicity detection before broadcasting. Cloudflare Workers AI has inference endpoints, which means this could run at the edge without a separate service. The signal-to-noise ratio on a small, obscure anonymous chat room is hopefully low enough that v1 holds for now.
There’s also rate limiting: one message per second per connection, 500 character max. Both enforced server-side. The client can lie about anything except the rate at which it sends WebSocket frames.
The stack in full
jefeorbot.com/beacon ← Astro page (SSR, no prerender)
└── vanilla JS WebSocket client
└── wss://jefeorbot.com/api/beacon ← Astro API route
└── Cloudflare Durable Object: BeaconRoom
├── WebSocket Hibernation API
├── Anonymous hex IDs (per-session)
├── Rate limiting (1msg/sec, 500 chars)
└── Content filter (regex v1)
No database. No message queue. No origin server. No accounts. No cookies.
The entire real-time infrastructure is one TypeScript class running on Cloudflare’s edge network, waking up when needed and sleeping when not.
What comes next
A few things I’m thinking about:
ML moderation. Replace or augment the regex filter with a proper toxicity classifier. Cloudflare Workers AI makes this plausible without a separate backend.
Multiple rooms. The current architecture supports this without significant changes — Durable Objects are identified by name, so idFromName('global') could become idFromName(roomSlug). The routing logic and the UI would need work.
End-to-end encryption. Right now, messages are encrypted in transit (TLS) but the Durable Object sees plaintext. True E2E would require key exchange between clients, which is possible but adds significant complexity. Interesting problem.
Operator visibility. I can currently observe nothing about what happens in the beacon — which is the point — but some kind of aggregated metrics (message volume, connection count, nothing content-related) would be useful for knowing if it’s actually being used.
Update — relay suspended: The live WebSocket relay at /api/beacon is turned off; the /beacon page documents offline status. The Durable Object implementation remains in the repo if this ships again. Until then, what’s written above is architectural truth — what’s running is a static status channel.
That’s fine. Most beacons are intermittent or imaginary until proven otherwise.