Implementing WebRTC for P2P Data Exchange on Website
WebRTC not limited to video. RTCDataChannel — low-level data channel between browsers with 20–50 ms latency, works over SCTP/DTLS without server mediator for data transmission. File exchanger, collaborative editor, game backend, mesh sync — all built on DataChannel.
RTCDataChannel vs WebSocket
| Parameter | WebSocket | RTCDataChannel |
|---|---|---|
| Data route | Client → Server → Client | Client → Client (P2P) |
| Latency | 50–200 ms (via server) | 20–60 ms (direct) |
| Reliability | TCP (ordered, reliable) | Configurable |
| Encryption | TLS | DTLS (mandatory) |
| Server load | All traffic | Signal only |
Main advantage — data doesn't go through your servers. Critical for file exchange, E2E-encrypted chats, private game sessions.
Creating DataChannel
// Offerer
const pc = new RTCPeerConnection({ iceServers: [/* ... */] });
const channel = pc.createDataChannel('files', {
ordered: true, // TCP semantics
// maxRetransmits: 0, // UDP semantics
// maxPacketLifeTime: 100,
});
channel.binaryType = 'arraybuffer';
channel.bufferedAmountLowThreshold = 65536; // 64 KB
channel.onopen = () => console.log('DataChannel open');
channel.onclose = () => console.log('DataChannel closed');
channel.onmessage = (e) => handleMessage(e.data);
// Answerer
pc.ondatachannel = (e) => {
const remoteChannel = e.channel;
remoteChannel.onmessage = (e) => handleMessage(e.data);
};
Reliability Modes
SCTP under DataChannel allows configuring delivery semantics:
- ordered + reliable (default) — guaranteed order, retransmission. For files, chat.
-
unordered + unreliable (
maxRetransmits: 0) — UDP-like. For game positions, cursors. - ordered + maxPacketLifeTime — packet lives N ms, then dropped. For voice commands, keyboard input.
File Transfer via DataChannel
Browser DataChannel limited ~256 KB message size. Files need chunking:
const CHUNK_SIZE = 64 * 1024; // 64 KB
async function sendFile(channel, file) {
const metadata = JSON.stringify({
name: file.name,
size: file.size,
type: file.type,
chunks: Math.ceil(file.size / CHUNK_SIZE),
});
channel.send(metadata);
const buffer = await file.arrayBuffer();
let offset = 0;
function sendNextChunk() {
while (offset < buffer.byteLength) {
// Buffer overflow control
if (channel.bufferedAmount > channel.bufferedAmountLowThreshold * 2) {
channel.onbufferedamountlow = () => {
channel.onbufferedamountlow = null;
sendNextChunk();
};
return;
}
const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
channel.send(chunk);
offset += CHUNK_SIZE;
}
channel.send(JSON.stringify({ type: 'transfer-complete' }));
}
sendNextChunk();
}
Receiver collects chunks:
let receivedSize = 0;
let receivedChunks = [];
let fileMetadata = null;
channel.onmessage = (e) => {
if (typeof e.data === 'string') {
const msg = JSON.parse(e.data);
if (msg.name) {
fileMetadata = msg;
} else if (msg.type === 'transfer-complete') {
const blob = new Blob(receivedChunks);
triggerDownload(blob, fileMetadata.name);
}
} else {
receivedChunks.push(e.data);
receivedSize += e.data.byteLength;
updateProgress(receivedSize / fileMetadata.size);
}
};
Progress and Speed
const startTime = Date.now();
let lastSize = 0;
function updateProgress(ratio) {
const elapsed = (Date.now() - startTime) / 1000;
const speed = (receivedSize - lastSize) / 1024; // KB/s last tick
lastSize = receivedSize;
progressBar.style.width = `${ratio * 100}%`;
speedLabel.textContent = `${(receivedSize / elapsed / 1024).toFixed(1)} KB/s`;
etaLabel.textContent = `${((fileMetadata.size - receivedSize) / (receivedSize / elapsed)).toFixed(0)} sec`;
}
Multi-Channel Architecture
Different data types — different channels with different reliability:
const channels = {
control: pc.createDataChannel('control', { ordered: true }),
files: pc.createDataChannel('files', { ordered: true }),
cursor: pc.createDataChannel('cursor', { ordered: false, maxRetransmits: 0 }),
chat: pc.createDataChannel('chat', { ordered: true }),
};
E2E Encryption over DTLS
DataChannel already DTLS-encrypted. For additional E2E (keys invisible even to server) use Web Crypto API:
// ECDH key exchange during signaling
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false, ['deriveKey']
);
// Public key sent via signaling server
const publicKeyExported = await crypto.subtle.exportKey('raw', keyPair.publicKey);
// After receiving partner's public key
const sharedKey = await crypto.subtle.deriveKey(
{ name: 'ECDH', public: partnerPublicKey },
keyPair.privateKey,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']
);
Mesh P2P for Multiple Participants
Up to 4–6 participants — full-mesh topology acceptable (each with each):
class MeshNetwork {
constructor(signalSocket) {
this.peers = new Map(); // userId -> RTCPeerConnection
this.channels = new Map();
this.signal = signalSocket;
}
async connectTo(userId) {
const pc = new RTCPeerConnection(ICE_CONFIG);
this.peers.set(userId, pc);
const channel = pc.createDataChannel('mesh');
this.channels.set(userId, channel);
channel.onmessage = (e) => this.onData(userId, e.data);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.signal.emit('offer', { to: userId, offer });
}
broadcast(data) {
const message = JSON.stringify(data);
this.channels.forEach(ch => {
if (ch.readyState === 'open') ch.send(message);
});
}
}
Typical Applications
Collaborative whiteboard — cursor positions via unreliable channel (~20 ms latency), draw operations via reliable. Delta sync every 100 ms.
Game match 1v1 — player state (position, action) via unordered channel 60 times/sec. Critical events (hit, death) via ordered reliable.
Peer-to-peer chat with files — text via ordered reliable, files chunked with buffer control, image previews as base64 in JSON.
Screenshare + annotation — video stream via RTCPeerConnection, annotations (click coords) via DataChannel.
Limitations and Pitfalls
-
Safari doesn't support
bufferedAmountLowThresholdin old versions — need polling viasetInterval - Firefox max message size 256 KB; Chrome varies by version
- Mobile browsers may close DataChannel when app backgrounds — need reconnect logic
- On connection loss
iceConnectionState === 'failed'— no auto-recovery, need explicit reconnect
Timeline
- Basic P2P file exchanger (2 participants) — 3–4 days
- Multi-user mesh with multiple channels — 1–2 weeks
- E2E encryption + key exchange — plus 3–4 days
- Collaborative editor / whiteboard over DataChannel — 2–4 weeks (including CRDT/OT logic)







