Handoff: a one-time secret that never touches our server
We built a text-sharing tool where the payload never touches a server, not even briefly. The interesting part was everything a server still has to do to make that true.
Paste text, get a link, it travels browser to browser over WebRTC. Here is what our relay is and is not allowed to see.
We built /handoff: paste text, get a link, the recipient's browser connects straight to yours and the text moves over WebRTC, no length limit, gone from both ends the moment it is read. The obvious way to build this is a server that holds the text until someone downloads it. We wanted a server that never holds it at all, not even for a millisecond. That constraint turned a text box into a real infrastructure problem.
The one thing you cannot avoid
Two browsers cannot just find each other. WebRTC gets you an encrypted, direct data channel once a connection exists, but establishing that connection means each side has to hand the other a session description (an SDP offer and answer) and a stream of ICE candidates, the possible network paths back to it. Neither browser knows the other exists until something introduces them. That introduction is the one piece of this that has to run through infrastructure we control. Everything after it does not.
A blind relay, not a mailbox
The introduction happens in a Cloudflare Durable Object, one per share link, using the WebSocket Hibernation API. First browser to connect is the offerer, second is the answerer, a third connection gets a 409. The object does not parse what it relays: whatever JSON the two peers send each other goes straight to the other side.
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
// Blind relay: whatever SDP/ICE JSON the client sends goes straight to
// the other peer. This Worker never parses or stores it.
this.other(ws)?.send(message);
}A 10-minute alarm closes the room if no one ever opens the link. Hibernation means a room sitting open for hours, waiting for someone to click, bills nothing while it waits: duration only accrues while a handler is actually running, and relaying a handshake takes milliseconds.
The key never leaves the address bar
WebRTC data channels are already encrypted, DTLS, peer to peer. We added a second layer anyway: AES-GCM, with the key generated in the sender's browser and appended to the share link as a URL fragment, the part after `#`. Browsers never send the fragment in an HTTP request, ever, to any server, including ours. Our own relay's logs cannot contain a key that was never transmitted to them. That is the actual point of a one-time secret: not that we promise not to look, but that there is nothing in our possession to look at.
TURN, minted where it is used
A meaningful fraction of real connections cannot go direct, corporate firewalls, symmetric NAT, and need a relay just to move packets, not read them. Cloudflare Realtime gives us that (TURN), and the Durable Object is already wired to mint a short-lived credential right after a peer connects, instead of exposing a `GET /ice-servers` route: a public credential-minting endpoint is a free-relay-for-anyone endpoint, and tying issuance to an actual room join is less code and a smaller abuse surface. What is live today is the fallback path: no TURN key is provisioned yet, so every connection currently negotiates on STUN alone. The code takes the credential and hands it to the client the moment we add the key, nothing else changes.
One static route, not a dynamic one
This site is `output: 'export'`, no server at request time. A room ID is generated client-side and random, so there is no way to pre-render a `[roomId]` page for it. `/handoff` sidesteps the problem instead of solving it: both the sender and receiver views are the same static page, and which one renders depends on whether `window.location.hash` has a room ID and key in it. Nothing about the routing needed a server either.
What this costs us: nothing
The payload never touches our infrastructure, so bandwidth for the actual text is not a line item, ever, regardless of how many people use this or how much they paste. What is left is the signaling relay, and that is free at the volume a tool on a marketing site actually sees. Durable Objects moved to the Workers Free plan in April 2026: 100,000 requests and 13,000 GB-seconds of duration a day, no charge. WebSocket Hibernation is the reason duration stays near zero even under real use: a room sitting open for minutes while someone finds the link and opens it costs nothing while it waits, since billed duration only accrues while a handler is actually running, and relaying a handshake is milliseconds of work. STUN is free and unlimited. TURN, if we turn it on, is free up to 1,000 GB a month of relayed traffic. A Workers Custom Domain costs nothing on any plan. Every piece of this was picked, in part, because the free tier was already enough.
What this does not do yet
- No TURN credentials are provisioned yet, so NAT traversal falls back to STUN only. Most direct connections work fine; a user behind a symmetric NAT or a locked-down corporate firewall may not connect at all until we turn TURN on.
- Both tabs have to be open and active at the same time. There is no push notification, no offline delivery. A backgrounded or suspended tab, especially on mobile Safari, can silently stall the handshake.
- The signaling room lives for 10 minutes. If the recipient does not open the link in that window, the sender has to generate a new one.
- Once the DataChannel opens, there is no delivery timeout. If the recipient never clicks the confirm button, the sender's copy just sits in that tab's memory until the tab closes. Nothing leaks, but nothing forces it closed either.
- "No length limit" means no artificial cap, not infinite. The whole payload is encrypted in one pass and reassembled in memory on both ends, so real size is bounded by what the browser can hold, not a number we picked.
- Refreshing either tab forfeits the session. There is no reconnect; start over with a new link.
- A room accepts exactly two connections. A third simultaneous joiner is rejected.
Next step: try /handoff, open the link in a second tab or send it to someone, and watch it disappear from both sides once they confirm. If you want something like this built into your own product, write to us at hello@gattyworks.com.