The pitch for P2P File Transfer is simple: send someone a file without uploading it anywhere. No S3 bucket, no file size limits, no account. The file goes straight 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 plus base64).
- Peer A shares the code with Peer B by copy-paste or QR code.
- 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 drag files onto each other.
After that no signaling server is involved; it’s genuinely peer-to-peer. I use Google’s public STUN servers only for ICE candidate discovery, which you can’t avoid for NAT traversal.
The encryption story
All files are encrypted with AES-GCM before they leave the browser. The key is derived from a shared session secret using PBKDF2. The secret rides along in the connection code, so it’s never transmitted separately from 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 a session fingerprint to detect MITM substitution. If someone tampers with the offer or answer codes during the out-of-band exchange, the fingerprints won’t match and the transfer aborts.
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'. Wait 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 that means thousands of chunks. The chunk queue needed backpressure, because if you send faster than the receiver can process, you overflow the DataChannel’s buffer and the transfer stalls. I watch bufferedAmount and pause sending when it crosses 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 scan, since QR codes get harder to read as data density rises. 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 clunky by nature. 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 hosting requirements at 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.