Tech 3 min read

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

  1. Caller: start mic → generate Offer → display QR 1/4, 2/4, 3/4, 4/4 in order
  2. Callee: start mic → scan 4 QR codes with camera → Answer auto-generated → display QR 1/4–4/4
  3. Caller: scan callee’s 4 QR codes
  4. Connection established: P2P call starts

Live Demo

Here’s a working demo.

P2P Voice Chat (WebRTC)

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.

References