WebRTC Signaling via QR Code: Experimenting with Fully Serverless P2P Calls
WebRTC P2P calls normally require a signaling server. But I wanted to know: can you really do it without one? I tried exchanging signaling data via QR codes.
The short answer: technically possible, but the UX is rough. It was an interesting experiment though, so here’s the writeup.
WebRTC and the Signaling Problem
WebRTC is fundamentally a P2P technology, but establishing the initial connection requires signaling.
sequenceDiagram
participant A as User A
participant B as User B
A->>B: 1. Offer (SDP)
B->>A: 2. Answer (SDP)
A-->>B: 3. P2P connection starts
B-->>A: 3. P2P connection starts
The challenge is how to exchange those “Offer” and “Answer” messages. Normally you’d use Firebase or a WebSocket server. Here, QR codes handle the exchange directly.
The Problem with SDP: It’s Too Big
WebRTC’s SDP (Session Description Protocol) contains codec info, ICE candidates, and more. Generating one produces something like:
SDP length: 2847
Base64 encoded: 3796
About 3.8KB. QR code capacity maxes out at roughly 2.9KB (binary mode), so it won’t fit in one code.
Solution: Split QR Codes
Split the SDP into 1KB chunks and exchange multiple QR codes.
const CHUNK_SIZE = 1000;
function createChunks(data: string): string[] {
const chunks: string[] = [];
const totalChunks = Math.ceil(data.length / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const chunk = data.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
// Format: index|total|data
chunks.push(`${i}|${totalChunks}|${chunk}`);
}
return chunks;
}
The reader accumulates chunks as they arrive, regardless of order, and reassembles once all are received.
const receivedChunks = new Map<number, string>();
function onQrDetected(raw: string) {
const parsed = parseChunk(raw);
if (!parsed) return;
receivedChunks.set(parsed.index, parsed.data);
// Reassemble when all chunks received
if (receivedChunks.size === parsed.total) {
const sorted = [...receivedChunks.entries()]
.sort((a, b) => a[0] - b[0]);
const fullData = sorted.map(e => e[1]).join('');
processCompleteSdp(fullData);
}
}
ICE Candidate Gathering
WebRTC requires ICE (Interactive Connectivity Establishment) candidate gathering — this is the routing information for NAT traversal, obtained by querying STUN servers.
const config: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
]
};
peerConnection = new RTCPeerConnection(config);
The SDP should only be output after ICE gathering completes.
function waitForIceGathering(timeout = 5000): Promise<void> {
return new Promise((resolve) => {
if (peerConnection.iceGatheringState === 'complete') {
resolve();
return;
}
const timeoutId = setTimeout(() => resolve(), timeout);
peerConnection.onicegatheringstatechange = () => {
if (peerConnection.iceGatheringState === 'complete') {
clearTimeout(timeoutId);
resolve();
}
};
});
}
The timeout exists because ICE gathering sometimes doesn’t complete depending on the network environment.
Audio Settings: Preventing Feedback
Enable echo cancellation and related options when getting audio.
localStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
See the audio feedback prevention article for details.
The Full Connection Flow
- Caller: start mic → generate Offer → display QR 1/4, 2/4, 3/4, 4/4 in order
- Callee: start mic → scan 4 QR codes with camera → Answer auto-generated → display QR 1/4–4/4
- Caller: scan callee’s 4 QR codes
- Connection established: P2P call starts
Live Demo
Here’s a working demo.
Testing on PC + smartphone confirms the P2P call works.
Conclusion
Technically possible. Fully serverless P2P voice calls work with just QR code exchange.
But practical usefulness is questionable:
- Scanning 4 QR codes in order is tedious
- Adding video would need even more QR codes
- Just use LINE call
Still, it was a good experiment for understanding how WebRTC signaling actually works. Figuring out what’s possible under the constraint of “P2P with no server” was genuinely interesting.