Planning poker with no backend: WebRTC, Nostr and a host election
How a planning poker app runs with no backend: WebRTC signaling over public Nostr relays, one-writer state sync, secret votes and coordinator-free failover.
· webrtc / p2p / architecture / serverless
Open a planning poker room, open DevTools, and watch the Network tab while your team votes. You’ll see a handful of
WebSocket connections open in the first second, and then no new requests at all. No polling, no API calls, no
POST /vote.
The votes are still moving, just not through anything that shows up there.
That’s because there is no backend. The site is a folder of static files on a CDN. A room exists only while people are in it: their browsers find each other, open direct WebRTC connections, and keep the room’s state among themselves. When the last tab closes, the room is gone, and no server ever saw it.
This post walks through how that works, layer by layer, using the real code.
TL;DR. Static Astro site on Cloudflare Pages. Browsers meet through public Nostr relays, which carry encrypted WebRTC offers and answers and nothing else. After that, all traffic is browser-to-browser. One browser, the host, owns the room state and broadcasts versioned snapshots. If it leaves, every other tab runs the same pure function and picks the same successor.

See it yourself in a minute
- Start a room and open the link in a second browser window (or a private one).
- In Chrome, open
chrome://webrtc-internalsin a third tab. In Firefox, it’sabout:webrtc. - Vote in both windows. You’ll see an
RTCPeerConnectionper peer, its ICE candidate pairs, and the data channel’s byte counters ticking up with every click.
What “no server” actually means
“Serverless” usually means “someone else’s server”. Here it means something stricter: no server we run, and no server that ever sees a vote. There is still infrastructure on the path. This is all of it, and what each piece can see:
| Piece | Who runs it | What it does | What it can see |
|---|---|---|---|
| Cloudflare Pages | CDN | Serves HTML, JS, CSS | That someone loaded the site. Not the room id (it lives in the URL hash). |
| STUN servers | Cloudflare, Google | Tell a browser its public IP and port | Your IP. Nothing about the room. |
| Nostr relays (×5) | Public volunteers | Pass connection offers between browsers | Your IP, a hashed room topic, encrypted blobs, timing. |
| TURN relay | You, optionally | Relays traffic when a direct path is impossible | Encrypted DTLS packets only. |
| Your teammates’ browsers | Your team | Everything else | The room. |
The rest of the post follows the numbers in that diagram.
A static site, and a room id the server never sees
Step ① in the diagram. The whole app is an Astro build: plain HTML pages with React islands for the interactive
parts. pnpm build writes a dist/ folder and Cloudflare Pages serves it. There is no SSR, no edge function and no
API route.
A room link looks like this:
https://planning-poker.ochirdarmaev-4da.workers.dev/room/#k3v9q2xa
The part after # is the room id. Browsers never send the fragment in an HTTP request, so the CDN serves the same
/room/index.html to everyone and has no idea which room you are in. That also means no _redirects file and no
catch-all routing: one HTML file serves every room.
Because the page is static, it can afford a strict Content Security Policy. Astro hashes every inline script and
style at build time, and connect-src only allows 'self' and wss:, the WebSocket relays used for signaling.
Finding each other: signaling over Nostr
Steps ② and ③. WebRTC can connect two browsers directly, but it can’t introduce them. Before a connection exists, each side has to hand the other a session description (SDP): codecs, a DTLS fingerprint, and a list of ICE candidates, the addresses it might be reachable on. Carrying that first message is called signaling, and WebRTC leaves it to you.
The usual answer is a WebSocket server you run. We use Trystero, which does signaling over existing public networks instead. Our default is Nostr, a protocol for relaying signed JSON events over WebSockets, with many free public relays.
Here is what happens when Bob opens a link Alice already has open:
A few details matter here:
- The topic is hashed. Relays see
sha1("Trystero:" + appId + ":" + roomId), not the room id. Trystero maps that topic to a Nostr eventkindin the 20000–29999 range. Under NIP-01, that range is ephemeral: relays forward those events to subscribers and don’t store them. - Offers are encrypted. Trystero encrypts every SDP with AES-GCM, using a key derived from the room password, the app id and the room id. Without a password, anyone who has the link can derive the key. That is fine, because the link is the invitation. With a password, the relays and anyone holding only the link see ciphertext they can’t use.
- The room id is the secret, and it’s short. It’s 8 characters from a 31-character alphabet, about 40 bits. That’s plenty against someone guessing links, but a relay operator who sees the hashed topic could brute-force it offline and then derive the no-password key. If a room matters, set a password: it’s mixed into the key and never published.
- Five relays, not one. Each browser connects to five relays from a list of public ones and publishes to all of them. One relay going down, rate-limiting you, or being blocked by a firewall doesn’t break the room.
appIdnamespaces deployments. Two forks of this app with differentPUBLIC_APP_IDvalues can’t see each other’s rooms, even with the same room id.
We load the signaling strategy lazily, so the Nostr client isn’t in the initial bundle of the landing page:
// src/p2p/config.ts (trimmed)
export async function loadStrategy(strategy: Strategy = STRATEGY) {
switch (strategy) {
case 'mqtt': return await import('@trystero-p2p/mqtt');
case 'torrent': return await import('@trystero-p2p/torrent');
default: return await import('@trystero-p2p/nostr');
}
}
MQTT brokers and BitTorrent WebSocket trackers work the same way and are one environment variable away
(PUBLIC_SIGNAL_STRATEGY).
NAT traversal: STUN, and when you need TURN
Most laptops sit behind a NAT and don’t know their public address. A STUN request fixes that: the browser sends a
tiny UDP packet to stun.cloudflare.com:3478 and gets back the IP and port the packet appeared to come from. That
becomes a server-reflexive ICE candidate. Both sides then send packets to each other’s candidates at the same time,
which opens a hole in both NATs. This is UDP hole punching.
It works for most home and office networks. It fails behind symmetric NATs and strict corporate firewalls that
block outbound UDP. For those, you add a TURN server, which relays the encrypted packets. TURN is the one piece
you would actually run (or rent), so it is off by default and enabled with PUBLIC_TURN_URL. Even then, TURN only
moves DTLS ciphertext. It can’t read votes.
One writer: host-authoritative state
Step ④. Once connected, Trystero gives us a full mesh: every browser has a data channel to every other one. Using a mesh as a mesh, where everyone writes and everyone merges, means CRDTs or vector clocks. A planning poker room is too small to need that. Instead, the logic on top of the mesh is a star:
- One browser is the host. Its copy of the
Roomis authoritative. - Everyone else sends intents. “I voted”, “I changed my name”, “I’m spectating”. An intent is a request, not a change.
- Only the host runs reducers. They are pure functions,
(room, peerId, intent) => room, and every change bumpsrev. - The host broadcasts the whole room. No diffs, no patches. A room is a few kilobytes at most, so sending all of it makes every snapshot self-contained and every bug recoverable: the next snapshot overwrites it.
The wire protocol is three Trystero actions:
| Action | Direction | Payload |
|---|---|---|
hello |
joiner → everyone | { name, spectator, joinedAt, team? } |
intent |
peer → host | one of cast, retract, value, change, rename, spectate, team |
snapshot |
host → everyone | the full Room, including rev and hostId |
Every payload comes from a browser we don’t control, so every payload is validated with zod before it reaches a reducer. A malformed message is dropped, not trusted:
// src/p2p/protocol.ts
export const intentSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('cast'), round: z.number().int() }),
z.object({ type: z.literal('retract'), round: z.number().int() }),
z.object({ type: z.literal('value'), round: z.number().int(), card }),
z.object({ type: z.literal('change'), round: z.number().int(), card }),
z.object({ type: z.literal('rename'), name }),
z.object({ type: z.literal('spectate'), spectator: z.boolean() }),
z.object({ type: z.literal('team'), team: teamName.nullable() }),
]);
private onIntent(peerId: PeerId, data: unknown): void {
const intent = parse(intentSchema, data);
const room = this.state.room;
if (!intent || !room || !this.isHost()) return;
this.commit(applyIntent(room, peerId, intent));
}
Note that peerId comes from the connection the message arrived on, not from the payload. A peer can’t put someone
else’s id in a message and vote on their behalf.
Keeping reducers pure pays off twice. Unit tests are just expect(reduce(room, intent)).toEqual(…) with no network.
And when the host role moves, nothing else has to move with it: the new host already has the state and the same
functions.
Secret votes: what leaves your browser, and when
The point of planning poker is that nobody sees anyone’s card until the reveal. In a server-based app you trust the server to hide them. Here there is no server to trust, so the card never leaves your browser before the reveal, not even to the host.
The client side is short. Picking a card stores it in local state and sends only a cast intent:
// src/p2p/room.ts (trimmed: the after-reveal branch is left out)
pick(card: string): void {
const { room, myCard } = this.state;
if (!room || room.peers[this.selfId]?.spectator) return;
if (myCard === card) {
roomStore.setState({ myCard: null });
this.sendIntent({ type: 'retract', round: room.round });
return;
}
roomStore.setState({ myCard: card });
if (!room.voted[this.selfId]) this.sendIntent({ type: 'cast', round: room.round });
}
When a snapshot arrives with revealed: true, each voter’s reconcile() step notices that the host has its voted
flag but no value, and sends the card. The host accepts one value per voter, for the current round only, so a late
or replayed message from an earlier round is ignored. The host’s own card is merged locally at the same moment.
That same reconcile() step makes the protocol self-healing. After every accepted snapshot, a peer compares the
host’s view of it with its own. If the host doesn’t list it, it sends hello again. If its vote is missing, it sends
cast again. If its value is missing after the reveal, it sends value again. Lost messages are repaired by the next
snapshot, with no acks and no retry timers.
Threat model, honestly. This keeps cards hidden from the host and from the relays. It can’t stop a teammate who patches the JS in their own tab from waiting until the other values arrive and then sending a different card. The app allows changing a card after the reveal by default anyway, so that is a feature setting rather than a hole. Planning poker is a tool for honest teams, not a sealed-bid auction.
Joining late, and rooms nobody is in
A joiner sends hello to every peer it connects to. Only the host acts on it: it adds the peer through
upsertPeer() and broadcasts. Everyone else ignores it. The joiner doesn’t need to know who the host is in advance,
because the first snapshot it receives says so.
What if nobody answers? A link might be reopened the next morning, after everyone has left. A joiner that hears no snapshot within six seconds starts the room itself:
// src/p2p/room.ts
export const SNAPSHOT_TIMEOUT_MS = 6000;
private startAlone(): void {
if (this.state.room) return;
// Nobody answered: the room is empty (or unreachable), so start it ourselves.
// If a host shows up later, the snapshot-merge rule settles who keeps it.
this.commit(createRoom(this.selfId, this.me, DECKS[DEFAULT_DECK_ID]));
}
That creates a real race. Two people open a stale link at the same time, both time out, and both become host of their own room. As soon as their browsers connect, each receives a snapshot from someone claiming to be host. A single deterministic rule settles it:
// src/p2p/host.ts
export function shouldAcceptSnapshot(local: Room | null, incoming: Room, senderId: PeerId): boolean {
if (local && senderId === local.hostId) return true; // our host, in order on one channel
if (incoming.hostId !== senderId) return false; // you can only claim the role for yourself
if (!local) return true;
if (incoming.rev !== local.rev) return incoming.rev > local.rev; // more history wins
return incoming.hostId < local.hostId; // tie: lower id wins
}
Both sides evaluate the same rule on the same pair of snapshots, so they reach the same verdict. The loser accepts the
winner’s room and re-sends its hello and vote through reconcile().
Host failover without a coordinator
The host is just a browser tab. Laptops sleep, Wi-Fi drops, people close tabs. A tab that closes normally sends a
leave message on its way out, so the others know at once. A laptop that drops off Wi-Fi can’t say goodbye, so the
others find out a few seconds later, when WebRTC notices the connection is dead. Either way, Trystero fires
onPeerLeave in every remaining tab, and each tab runs this:
// src/p2p/host.ts
export function electHost(room: Room, excluding: PeerId): PeerId | null {
let best: { id: PeerId; joinedAt: number } | null = null;
for (const [id, info] of Object.entries(room.peers)) {
if (id === excluding) continue;
if (!best || info.joinedAt < best.joinedAt ||
(info.joinedAt === best.joinedAt && id < best.id)) {
best = { id, joinedAt: info.joinedAt };
}
}
return best?.id ?? null;
}
There is no voting round, because every tab already holds the same last snapshot, and electHost is a pure function
of it. The tab that elects itself commits the room with the old host removed (which bumps rev) and broadcasts.
Everyone else points hostId at the winner and waits for that snapshot, which they accept because it comes from the
host they now follow.
Two details keep this stable:
joinedAtsurvives reconnects. When a peer re-announces itself,upsertPeerkeeps its originaljoinedAt, so a flaky connection doesn’t send someone to the back of the line or make them jump it.- Clocks barely matter.
joinedAtcomes from each peer’s own clock, but the value is set once and then copied from the snapshot. Every tab compares the same numbers, so skew can change who wins, but not whether everyone agrees.
A host can also hand the role over on purpose from a seat’s menu. That is a normal reducer (transferHost), and the
receiving tab claims it by committing rev + 1.
Surviving real networks
Browsers are hostile to long-lived connections, so the controller watches for the events that break them:
onlineafter a network change: leave and rejoin signaling. State comes back from peers’ snapshots.visibilitychange: if the tab wakes up expecting peers but holding zero connections, rejoin. Mobile browsers often kill WebRTC in background tabs.pagehide/pageshow: leave cleanly when the page is hidden, and rejoin if it’s restored from the back/forward cache.
None of this needs special recovery logic. A rejoin is just a join for a peer that already has state, and the
snapshot rule plus reconcile() bring everyone back into line.
Trade-offs
This design is a good fit for planning poker, and it would be a poor fit for many other things.
- Mesh size. n browsers open n·(n−1)/2 connections, and the host sends n−1 snapshots per change. For a scrum team that’s trivial: 10 people is 45 connections and a few kilobytes per change. It isn’t how you’d build a 500-person webinar.
- No persistence. When the last person leaves, the room is gone. The session log lives in each tab’s memory and can be exported as CSV; there is no database to query later.
- Public infrastructure. Nostr relays are run by volunteers, can rate-limit, and can see IPs and timing metadata. Five-way redundancy and encrypted payloads cover availability and content, not metadata.
- Restrictive networks. Without TURN, some corporate networks can’t connect peer-to-peer at all. That is the price of “no server”, and the one place you might choose to pay it.
- Trust inside the room. Anyone with the link (and password, if set) is a full participant. That is the same model as a meeting link.
What it costs to run
Static hosting on Cloudflare Pages’ free tier, public STUN, public relays: $0 a month. More rooms don’t cost more, because each room runs on its participants’ laptops. There is no database to back up, no server to patch and no data to leak, because none is collected. The only state that exists is in the tabs of the people estimating.
Steal this pattern
None of this is specific to planning poker. If you’re building something small and real-time, like a retro board, a shared timer, a quiz or a two-player game, the recipe carries over:
- Put the session id in the URL hash. It routes nothing, and the host never logs it.
- Borrow signaling. Public Nostr relays, MQTT brokers or BitTorrent trackers will introduce peers for free.
- Pick one writer. A star on top of a mesh saves you from CRDTs when the data is small.
- Send whole snapshots with a revision number. Lost messages fix themselves on the next one.
- Make every decision a pure function of shared state. Then peers agree without talking: on who wins a race, and on who becomes host.
Try it
Open a room with a teammate, keep chrome://webrtc-internals open in another tab, and close the host’s tab
mid-round. The cards stay on the table, and someone else becomes host.