WebRTC File Transfer: Browser-to-Browser Tutorial
Transfer files directly between browsers using WebRTC. Data channels, signaling, and peer connection setup for real-time file sharing.
WebRTC file transfer moves bytes directly between two browsers using an RTCDataChannel, with no server sitting in the data path after the initial handshake. The pieces you need: a signaling channel (WebSocket or any tiny relay) to exchange SDP offers and ICE candidates, STUN servers for NAT discovery, a TURN fallback for symmetric NATs, and a data channel configured for reliable ordered delivery. Once the peer connection is up, you channel.send() 16 KB-256 KB chunks and watch the bytes flow over a DTLS 1.2 encrypted SCTP association at roughly network-line speed.
How WebRTC Actually Gets You Peer-to-Peer
WebRTC is not magic — it's ICE plus SDP plus DTLS plus SCTP stacked together. The sender creates an RTCPeerConnection, opens a data channel, generates an SDP offer, and sends it to the recipient via your signaling channel. The recipient answers. Both sides then trade ICE candidates (local IP, reflexive IP via STUN, relay IP via TURN) until they find a working path. DTLS 1.2 handshakes end-to-end, SCTP rides on top for reliable streaming, and your bytes start flowing.
The encryption is mandatory and built in. You cannot opt out. That's a meaningful security win — unlike WebSockets, you don't need to remember to wrap anything in application-layer crypto to protect against network eavesdroppers. For real E2EE against your own signaling server, layer a second round of AES-256-GCM on top, because a malicious signaling server could swap in its own DTLS certificate.
Signaling: The Part WebRTC Doesn't Define
WebRTC deliberately leaves signaling to you. A WebSocket on your server is fine; so is a shared room code posted to a Firebase realtime DB, or even manually pasted SDP strings. What matters is both peers eventually exchange an offer, an answer, and a stream of ICE candidates.
Minimal signaling server in Node:
const rooms = new Map();
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'join') {
const room = rooms.get(msg.room) ?? new Set();
room.add(ws); rooms.set(msg.room, room);
} else {
for (const peer of rooms.get(msg.room) ?? []) {
if (peer !== ws) peer.send(raw);
}
}
});
});
Under 20 lines. The server never sees file bytes — only SDP and ICE metadata. You can host this on a $5 VPS or Cloudflare Workers and serve hundreds of concurrent transfers.
Standing Up the Peer Connection
Create the connection with Google's public STUN plus a TURN fallback:
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turn.example.com:3478',
username: 'user', credential: 'pass' }
]
});
About 15-25% of residential connections sit behind symmetric NATs that STUN can't punch through, so TURN is not optional for a production service. Run coturn on a VPS with enough bandwidth to handle relayed traffic, or pay for a managed TURN provider like Xirsys or Twilio.
Open the data channel on the offerer's side before creating the offer:
const channel = pc.createDataChannel('file', {
ordered: true, maxRetransmits: null
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: 'offer', sdp: offer.sdp });
Ordered + unlimited retransmits gives you TCP-equivalent reliability. Unordered mode is faster but needs application-level reassembly.
Chunking the File for the Channel
SCTP has a practical 256 KB per-message limit, and older browsers struggle above 16 KB. A safe default is 16 KB chunks read from the file via File.slice():
const chunkSize = 16 * 1024;
let offset = 0;
channel.bufferedAmountLowThreshold = 1024 * 1024;
function sendNext() {
while (offset < file.size && channel.bufferedAmount < 4 * 1024 * 1024) {
const chunk = file.slice(offset, offset + chunkSize);
chunk.arrayBuffer().then((buf) => channel.send(buf));
offset += chunkSize;
}
}
channel.onbufferedamountlow = sendNext;
sendNext();
The bufferedAmount watermark prevents you from queueing gigabytes into SCTP's send buffer and running out of memory. When buffer drops below 1 MB, refill to 4 MB. This gives throughput close to 40-80 MB/s on local networks and 5-20 MB/s on typical residential broadband.
Receiving and Writing Bytes to Disk
On the answering side, listen for the incoming channel:
pc.ondatachannel = ({ channel }) => {
const chunks = [];
let received = 0;
channel.onmessage = ({ data }) => {
chunks.push(data);
received += data.byteLength;
updateProgress(received);
if (received === expectedSize) finish(chunks);
};
};
For files larger than 500 MB, don't accumulate in RAM. Use the File System Access API to stream directly to disk:
const handle = await window.showSaveFilePicker({ suggestedName: fileName });
const writable = await handle.createWritable();
channel.onmessage = async ({ data }) => writable.write(data);
Firefox and Safari don't yet support showSaveFilePicker, so fall back to a Blob + URL.createObjectURL download for those browsers, capped at 2 GB.
Sending File Metadata Before Bytes
The receiver needs to know file name, size, and MIME type before the byte stream starts. Use a tiny JSON handshake on the data channel:
channel.send(JSON.stringify({
type: 'metadata', name: file.name,
size: file.size, mime: file.type, sha256: fileHash
}));
Then switch to binary mode. The receiver toggles based on whether data is a string or ArrayBuffer. Include a SHA-256 of the file for integrity verification post-transfer, and optionally include a key fingerprint if you're layering application-level AES-GCM on top.
Dealing With Connection Failures
WebRTC data channels fail in three ways: ICE never completes (NAT, firewall blocks), DTLS handshake fails (clock skew, cert issues), or the connection drops mid-transfer (laptop sleep, network change). Listen for pc.oniceconnectionstatechange and act on 'failed' or 'disconnected'. Chrome keeps 'disconnected' for a few seconds before moving to 'failed'; Safari is less patient.
On failure mid-transfer, restart ICE without rebuilding the whole connection:
await pc.restartIce();
const offer = await pc.createOffer({ iceRestart: true });
// re-send via signaling
If restart fails, fall back to resumable upload via your server — a hybrid P2P + server architecture. Some WebRTC transfer tools like Wormhole and justbeamit use this pattern because it handles the 15% of network conditions where pure P2P just can't work.
Adding End-to-End Encryption Against Your Own Server
WebRTC's built-in DTLS protects against network attackers but not against a malicious or compromised signaling server. For true E2EE, have both peers generate an ECDH P-256 keypair, exchange public keys over a short out-of-band code (QR or 6-word passphrase), derive a shared secret via HKDF-SHA256, and encrypt each data channel message with AES-256-GCM before calling send. That way even if the signaling server swaps DTLS certs, it can't read your files.
HexaTransfer uses a hybrid architecture — ciphertext stored server-side with client-side AES-256-GCM, rather than P2P — trading directness for offline-tolerant sharing. Try it at hexatransfer.com — free, no account, 10 GB max.
Where WebRTC Wins and Loses
WebRTC file transfer shines when both peers are online simultaneously, when privacy from your own server matters, and when files are large enough (100 MB+) that relay bandwidth costs would be painful. It loses when users want to send and walk away, when recipients open the link hours later, or when recipients are on restrictive corporate networks that block STUN and TURN. For a general-purpose transfer tool, pure P2P covers maybe 60% of use cases comfortably. The other 40% need a server-backed fallback — which is why almost every "P2P file transfer" product has a relay in the architecture somewhere.
Send large files securely with end-to-end encryption
Transfer files up to 10 GB for free with end-to-end encryption. No account required. Your files are encrypted in your browser before upload — no one else can read them.
Send a file