WebRTC vs WebSockets for P2P File Sharing: What I Learned
Why I chose WebSockets over WebRTC for PeerDrop, the tradeoffs involved, and how to handle large file transfers efficiently.
The Initial Plan: WebRTC
When I started PeerDrop, WebRTC seemed like the obvious choice. True peer-to-peer, no server in the data path, browser-native. I spent a week implementing it.
Then I hit the real world.
Why WebRTC Failed for My Use Case
NAT traversal is unreliable. WebRTC uses STUN/TURN servers to punch through NATs. STUN works maybe 70% of the time. TURN (relay) works always, but then you're routing data through a server anyway — defeating the purpose.
No progress on large files. The DataChannel API doesn't give you reliable progress events for large transfers. You have to implement your own chunking and reassembly.
Connection setup is slow. ICE negotiation takes 2-5 seconds. For a file sharing tool, that's a bad first impression.
The WebSocket Approach
I switched to WebSockets with a Go backend. The architecture:
- Sender connects, gets a room code
- Receiver connects with the room code
- Server acts as a relay — it never stores the file, just streams bytes from sender to receiver
func handleTransfer(sender, receiver *websocket.Conn) {
buf := make([]byte, 32*1024) // 32KB chunks
for {
n, err := sender.Read(buf)
if err != nil { break }
receiver.Write(buf[:n])
}
}
Chunking for Progress
On the client, we chunk the file and send metadata first:
const CHUNK_SIZE = 64 * 1024; // 64KB
async function sendFile(file: File, ws: WebSocket) {
// Send metadata
ws.send(JSON.stringify({ name: file.name, size: file.size, type: file.type }));
// Send chunks
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(offset, offset + CHUNK_SIZE);
ws.send(await chunk.arrayBuffer());
offset += CHUNK_SIZE;
onProgress(offset / file.size);
}
}
End-to-End Encryption
Since data now passes through the server, E2E encryption is non-negotiable. We use the Web Crypto API:
// Generate a key and share it via URL fragment (never sent to server)
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
const exported = await crypto.subtle.exportKey("raw", key);
const keyHex = Buffer.from(exported).toString("hex");
// Share: peerdrop.app/receive/ROOMCODE#KEY
The key is in the URL fragment — it's never sent to the server in HTTP requests.
The Tradeoff
Yes, data passes through my server. But with E2E encryption, the server sees only ciphertext. And the reliability improvement is massive — 100% connection success vs ~70% with WebRTC.
For a tool where "it just works" matters more than theoretical purity, WebSockets won.