The idea for Relay came from a genuine grievance: university lab computers. Every semester it was the same dance — you need one file off a lab machine, the machine has no USB ports unblocked, email is logged in, WhatsApp Web asks you to trust a shared device with your whole chat history, and the nearest secure alternative is "walk home and remember to bring your laptop tomorrow." The lab machines were fine; the transfer options were the problem. Every option either installed something, logged in somewhere, or uploaded the file to a third party you were meeting for the first time and a lifelong relationship with, apparently.
I wanted none of those, so Relay became a set of constraints before it became a codebase: no login, no phone number, no account; the file transfers browser-to-browser; nothing persisted anywhere beyond the life of a tab; and pairing two devices should take less time than finding a USB cable. Those constraints landed me — happily — on WebRTC data channels, and this article is the honest account of building it: how the connection actually forms, where every design decision bites back, and what I would tell anyone starting the same build.
Why WebRTC instead of an upload server (and why this ever matters)
The default architecture everyone reaches for — upload to a server, share a link, download — is simple and, for most products, correct. It has three properties that made it wrong for Relay's job:
- Storage liability. Anything that persists is a leak risk and a compliance surface. A transfer tooling holding other people's files is one config mistake from hosting content nobody put it there to hold.
- Identity gravity. Server-mediated transfer practically begs for accounts: "who uploaded this" needs an answer eventually, and the answer is usually "sign up." Relay's whole point was transferring without identity.
- Policy and abuse surface. A public upload endpoint gets scraped, spammed, and used for things you did not design for, within days of discovery.
WebRTC sidesteps all three by construction: the file bytes flow over a direct, encrypted (DTLS) connection between the two browsers. The server participates in the handshake but never in the contents. For a transfer tool whose entire value proposition is leaving no trace, that distinction is not an optimization — it is the product.
There is also a performance virtue, with an asterisk. Once the direct path forms, transfer speed depends on the two peers' connection, not your server's bandwidth — a real 100 MB send can saturate both ends' link without your infrastructure fielding a single byte. The asterisk: some network topologies defeat direct paths, and then a TURN relay — your server acting as a literal pipe — becomes the fallback, which we price and handle later seriously.
The connection, in three honest stages
Everything mysterious about WebRTC lives in how two browsers find each other across the internet. Break it into three stages and it stops being mysterious:
Stage 1 — signaling: the meeting arrangement
WebRTC has no built-in mechanism for one browser to "call" another; peers need an out-of-band channel to exchange connection descriptions. That channel is your signaling server — in Relay's case, Socket.IO over Node.js, because it gives rooms, presence, and reconnect semantics for the price of a few event handlers.
The payloads exchanged here are the SDP offer and answer — descriptions of what each peer wants (media, data channels, codecs) — and ICE candidates, the possible network addresses on which each peer may be reachable. Two facts worth tattooing somewhere: signaling data contains no file contents, and it is safe to send it over TLS through your own server; the payload is rendezvous metadata. The file never rides this pipe.
// Server: rooms + signaling relay (Socket.IO)
io.on('connection', (socket) => {
socket.on('room:join', ({ code }) => {
socket.join(code);
const peers = Array.from(io.sockets.adapter.rooms.get(code) ?? []);
if (peers.length === 2) io.to(code).emit('peer:ready');
});
socket.on('signal', ({ code, payload }) => {
socket.to(code).emit('signal', payload); // offer/answer/candidate relay
});
});
Design decisions in this file are cheap-looking and load-bearing. Room codes are six digits — long enough to be effectively unguessable, short enough to type from a phone locked-to-camera distance. Rooms hold exactly two peers, and the second join triggers readiness. Anything beyond that state machine (three-way transfer, reconnecting peers) complicated the demo without serving the use case, so Relay says no to them explicitly.
Stage 2 — ICE, STUN, TURN: crossing the NATs
Most devices live behind NAT — home routers saying "this device exists here, trust me." Two peers cannot simply exchange private addresses; they need to discover their public reachability and try combinations until one path works. That negotiation is ICE, and each candidate type matters operationally:
- Host candidates — local addresses; work on the same network (same café Wi-Fi, a surprisingly common case for lab-machine transfers).
- Server-reflexive (STUN) candidates — "you appear to the internet as this address:port," discovered by asking a STUN server. Free, cheap, and works for the majority of home/mobile NAT setups.
- Relay (TURN) candidates — when symmetric NAT, carrier-grade NAT, or firewall weirdness blocks every direct path, traffic flows through a TURN server that your infrastructure pays to operate.
The operational surprise of every first WebRTC deployment is TURN economics. STUN is nearly free; TURN moves actual payload bytes through your servers — bandwidth, the expensive resource. My budgeting rule after seeing real patterns: provision TURN as insurance with tight usage caps and per-session limits, and treat a TURN-heavy usage profile as either a product problem (many peers behind hostile networks) or a location problem worth knowing about. Roughly 90%+ of Relay's sessions connect directly in my observed traffic, which is the honest reason a zero-storage privacy product remained affordable to run.
Stage 3 — the data channel: where the file actually goes
Data channels come in two reliability flavors, and choosing wrongly here is the classic first-build bug: ordered/ reliable (like TCP) or unordered/unreliable with partial reliability options (like UDP). For file payloads I use ordered and reliable — a missing chunk in the middle of a PDF is a corrupted PDF, not an artistic choice: correctness over latency, always, for this job.
const dc = pc.createDataChannel('file', {
ordered: true,
});
dc.binaryType = 'arraybuffer';
dc.bufferedAmountLowThreshold = 256 * 1024; // backpressure gate
Making a 100 MB transfer actually work
A naöve WebRTC send (dc.send(file)) fails at modest sizes in three distinct ways, and each taught me something about running software against real networks rather than demos.
1. The send buffer is finite and unforgiving. The data channel buffers internally; blasting gigabytes fills it, the send throws, states corrupt, everyone is mystified. The fix is backpressure: chunk the file, send a window, wait for the buffer to drain below the threshold via the bufferedamountlow event, send the next window. It looks like extra code the demo does not need and is the difference between 5 MB and 100 MB working.
const CHUNK = 16 * 1024; // 16 KB chunks
async function sendFile(file, dc) {
let offset = 0;
while (offset < file.size) {
while (dc.bufferedAmount > dc.bufferedAmountLowThreshold) {
await new Promise(r => dc.addEventListener('bufferedamountlow', r, { once: true }));
}
const slice = file.slice(offset, offset + CHUNK);
dc.send(await slice.arrayBuffer());
offset += CHUNK;
onProgress(offset / file.size); // real UI feedback
}
dc.send(JSON.stringify({ type: 'done', checksum }));
}
2. Metadata goes first, deliberately. Name, size, MIME type, and a checksum ride in a small JSON preamble so the receiving side can pre-allocate, show a real progress bar, and — this is the part users notice — know something is happening instead of staring at a spinner of unknown duration.
3. Verify at the end. The SHA-256 of the reconstructed file must match the declared checksum, or toast "transfer failed — try again." Users forgive a retry; they do not forgive silent corruption, and they will never tell you about it except as "your app broke my file."
The receiving side mirrors the structure: assembly via a stream into a Blob, progress from received bytes against declared size, checksum on completion, and an explicit permission prompt before any download button appears. Files should never silently save to someone's disk — that is the difference between a transfer tool and malware.
The notepad: the easy half after the hard one
Relay's shared notepad looks like an afterthought and is practically free once signaling exists: keystroke syncing over Socket.IO, plain room broadcasts, state as text-with-synced-cursor. Text does not care about chunking or checksums the way binaries do. I include it not as a feature checkbox but because real use cases arrived that wanted it — pasting a code snippet from a lab machine to a phone needed no file at all, just text with no account.
Privacy invariants, written as commitments
Products get trusted through specific, checkable claims, mine being:
- No file content persists server-side, ever — including logs.
- Rooms die with their participants; there is no server-side room history to subpoena or leak.
- No accounts, no cookies, no analytics on the transfer pages.
- Signaling traffic contains metadata only, and any TURN relay usage is disclosed.
Every feature request since has been evaluated against these. The ones that broke them — server-side history, "cloud room persistence," richer accounts — I have refused, and I mention that because "privacy-first" is easy to write and hard to keep when the roadmap is watching.
FAQ
What is the real maximum transfer size? Memory-bound on the receiving browser since the artifact assembles client-side; 100 MB is the flow I test and advertise, and chunked-streaming to disk could raise it. The cap is honest testing, not a hard protocol wall.
Does this work on mobile browsers? Yes — data channels and the flow work on modern mobile Chrome/Safari; the constraints are battery and background-tab throttling, which fights long transfers, so keep sends modest on mobile-to-mobile.
Cannot the browser's screen just show the file mid-transfer? The receiver sees progress and gets the assembled file at the end, with a checksum-verified download button. No intermediates, no partials, no surprise saves.
Why not WebTorrent? WebTorrent is a fine tool for public, swarm-style distribution. Relay's constraint was two-party, private, accountless transfer; adding a torrent swarm brings its own model, not less complexity.
Where does the code live? Relay is open source — signaling server, client flow, and all — and the case study covers the architecture at yet more depth.
The security model of WebRTC — what the encryption actually covers
Peer-to-peer gets described as "inherently secure," and while DTLS mandates encryption on data channels, the full security story has layers that matter when you architect for real users:
- What DTLS covers: the file bytes and channel payloads are encrypted between consenting browsers. Nobody in transit — your server included, in the direct case — can read them.
- What it does not cover: authorization. Any pair of browsers that completes signaling can open a channel. Relay's room codes function as bearer capability: possession of the six-digit code is the authorization. The design consequence is that codes are narrow (they expire with the room), single-use in practice, and never logged on the server — otherwise your security token is in your logs forever.
- Signaling integrity: the rendezvous messages travel through your server over TLS. An attacker who can tamper with SDP payloads can downgrade or misdirect the connection, so signaling has to be treated as an authenticated surface just like any other API — rate-limited, validated, and never echoing untrusted payloads into other clients' sessions unfiltered.
- consent boundaries: the receiving browser should confirm before accepting payloads into memory — that buffer is finite, which is both a UX nicety and a DoS surface: an uninvited peer blasting chunks at an unsolicited channel is a resource attack, and rejecting unknown senders before assembly is the correct posture.
For most products the summary is honest and simple: WebRTC gives you encrypted transport for free, and gives you authorization nowhere at all. The application builds the second half; budget for it.
Scaling and operating the signaling server
Relay's backend looks trivially small, and running it in production taught me the operational details that this class of service actually requires — worth listing for anyone building similar:
- Horizontal scale needs a pub/sub substrate. Socket.IO rooms are process-local; the moment you run two instances behind a load balancer, signaling events must route through Redis pub/sub (or equivalent) or peers in the same room never meet. Every Socket.IO-in-production bug report with "works locally" at its center is this fact wearing different costumes.
- Room garbage collection. Users abandon tabs. Without explicit lifecycle management, rooms and event listeners accumulate until memory pressure produces "random" disconnects. Relay reaps rooms on disconnect and runs a sweeper for zombie sockets, because GC-for-me-through-eventual-OOM is not a plan.
- Observability tuned to connection lifecycle: signaling latency, ICE success rate, TURN usage ratio. The ICE success percentage is the single metric that best predicts user complaints — when it drops, users are on networks the current STUN/TURN budgets no longer cover, and no code change fixes a network topology problem.
- Fail closed, message honestly. When signaling is down, Relay says so in one plain sentence rather than spinning. A transfer tool users trust on public machines earns that trust by never pretending something is working when it is not.
The sweet irony of operating this stack: the WebRTC layer — the part everyone treats as the scary research problem — has been the stable part. The operational incidents all lived in the ordinary territory of socket servers, memory lifecycle, and deployment plumbing. Respect the ordinary parts; they are where downtime lives.
Feature extensions worth considering (and their prices)
Nearly every inquiry I get for Relay-adjacent builds arrives with one of these extensions, so the costs are worth pre-registering:
- Multiple peers per room — mesh topologies multiply connections quadratically (n·(n-1)/2 channels); SFU-based approaches (an SFU server mediating the streams) restore linear scale at the price of reintroducing a server in the data path. Decide deliberately — it is the end of "no storage, no middle" as an absolute claim.
- Resumable transfers — persisted chunk offsets on the receiving side, keyed by transfer id. Feasible and contained; just note the metadata now persists across reloads, which bends the nothing-persists-invariant by one deliberate exception.
- Folder transfer — worth building once you need batch semantics: file-entry metadata framing, directory reconstruction, and considerably larger assembly buffers.
Testing a signaling server — the suite that earns trust
Because Relay holds a privacy promise, its test suite has to prove more than "it works." What carries the burden:
- Lifecycle tests: join, pair, dual-signal,BYEt disconnect, garbage collection — scripted against an in-memory harness, plus integration rounds against a real Node service in CI. The reconnect case (one client's flaky Wi-Fi mid-transfer) has its own dedicated suite, because that is the bug report that arrives when you don't.
- Cross-browser matrix: Chrome, Firefox, Safari (desktop and mobile), since each engine handles data-channel edge behavior slightly differently. Safari remains the honest outlier, and shipping without testing it is how products earn single-browser reputations.
- Adverse-network rounds: throttled, packet-lossy, and NAT-hostile environments — the layer where ICE failures actually reproduce. Most testing infrastructure for WebRTC exists to make these pages boring again.
- Privacy assertions: log-line inspection in CI asserting no file bytes, no room codes, no metadata leakage past the room's lifetime. A privacy promise that no test enforces is a promise that decays silently under normal development pressure.
Need real-time capability in your product — collaboration, live dashboards, peer-to-peer? Talk to Tayyab; real-time engineering is a daily part of my work, not a stretch assignment.



