The pitch for P2P File Transfer is simple: send a file to someone without uploading it anywhere. No S3 bucket, no file size limits, no account required. The file goes directly from your browser to theirs over an encrypted WebRTC DataChannel.
It’s been one of the most technically interesting projects I’ve worked on.
How it works
The basic flow:
- Peer A opens the app and clicks “Create Session.” The app generates a WebRTC offer and encodes it into a short connection code (gzip + base64).
- Peer A shares the code (copy-paste or QR code) with Peer B.
- Peer B pastes the code, which generates an answer code to send back to Peer A.
- Peer A pastes the answer code. The WebRTC handshake completes, a DataChannel opens, and both peers can now drag files onto each other.
No signaling server is involved after that — it’s genuinely peer-to-peer. I use Google’s public STUN servers only for ICE candidate discovery, which is unavoidable for NAT traversal.
The encryption story
All files are encrypted with AES-GCM before they leave the browser. The encryption key is derived from a shared session secret using PBKDF2 — the secret is included in the connection code, so it’s never transmitted independently of the signaling payload.
Each 64KB chunk gets its own AES-GCM encryption call with a fresh nonce. The receiver decrypts each chunk and verifies a SHA-256 hash against the expected value. Only after all chunks are received and verified does the browser assemble the final file.
The signaling payload also includes session fingerprinting to detect MITM substitution — if someone tampers with the offer/answer codes during the out-of-band exchange, the fingerprints won’t match and the transfer is aborted.
The hard parts
WebRTC in practice
WebRTC’s spec reads cleanly. The reality is messier. The handshake timing is fiddly — you have to gather ICE candidates before encoding the offer, which means waiting for iceGatheringState === 'complete'. Too long and the UX is slow; too short and the offer is incomplete.
Message ordering is also not guaranteed on DataChannels (though in practice it usually is, over UDP). I built an explicit sequence number into the chunk protocol and buffer out-of-order chunks on the receiver side just in case.
Large file support
DataChannel messages have a practical size limit well below what browsers advertise. I settled on 64KB chunks, which is conservative but avoids the edge cases where certain browser implementations silently drop larger messages.
For large files this means thousands of chunks. The chunk queue needed backpressure — if you send faster than the receiver can process, you overflow the DataChannel’s buffer and the transfer stalls. I monitor bufferedAmount and pause sending when it exceeds a threshold.
Resumable transfers via IndexedDB
If the connection drops mid-transfer, the state is preserved in IndexedDB. On reconnect, the sender reads the last acknowledged chunk number from the receiver and resumes from there. This required a clean protocol for acknowledging chunks and for the receiver to advertise its current position on reconnect.
QR code signaling
I added QR code generation as an alternative to copy-pasting the connection code. The main challenge was keeping the encoded payload small enough to produce a scannable QR code — QR codes get harder to scan as data density increases. Gzip compression of the SDP payload got the offer down to a size that produces a reasonable QR code in most cases.
What I’d change
The manual signaling UX is inherently clunky. The ideal version would have a lightweight relay for the initial handshake — just a few hundred bytes of SDP — so you could share a simple URL instead of a blob of encoded text. I kept it fully serverless to keep the hosting requirements zero, but there’s a real usability cost.
Try it
The app is deployed at p2p-transfer.netlify.app. Open it in two browser tabs (or on two different devices on the same network) and give it a try. The source is on GitHub.