WebRTC: Peer-to-Peer Communication

WebRTC (Web Real-Time Communication) is the technology that powers video calls in your browser without installing Zoom or Skype. It's a set of APIs and protocols that enable peer-to-peer audio,...

Key Insights

  • WebRTC enables direct browser-to-browser communication without plugins, but still requires a signaling server to coordinate the initial connection handshake between peers.
  • The ICE framework handles NAT traversal automatically, but you’ll need TURN servers as a fallback for roughly 10-15% of connections that can’t establish direct peer routes.
  • Data channels are criminally underused—they provide low-latency, encrypted communication for gaming, file sharing, and real-time collaboration without touching your servers.

Introduction to WebRTC

WebRTC (Web Real-Time Communication) is the technology that powers video calls in your browser without installing Zoom or Skype. It’s a set of APIs and protocols that enable peer-to-peer audio, video, and data transfer directly between browsers.

The “peer-to-peer” part is crucial. Once a connection is established, media and data flow directly between users without routing through your servers. This means lower latency, reduced bandwidth costs, and better privacy since you’re not an intermediary for user communications.

Browser support is excellent—Chrome, Firefox, Safari, and Edge all support WebRTC. Use cases extend beyond video chat: multiplayer gaming, collaborative document editing, file sharing, screen sharing, and IoT device communication all benefit from WebRTC’s low-latency direct connections.

Core Architecture & Concepts

WebRTC’s architecture confuses newcomers because “peer-to-peer” doesn’t mean “serverless.” You need infrastructure for two distinct purposes:

Signaling servers coordinate the connection handshake. They exchange connection metadata (SDP offers/answers) and network candidates between peers. WebRTC doesn’t specify how signaling works—use WebSockets, HTTP polling, carrier pigeons, whatever. The signaling server never sees the actual media or data.

ICE/STUN/TURN servers help peers find each other across the internet:

  • STUN (Session Traversal Utilities for NAT) servers tell peers their public IP address and port. Most connections only need STUN.
  • TURN (Traversal Using Relays around NAT) servers relay traffic when direct connections fail. They’re expensive to run but essential for symmetric NATs and restrictive firewalls.
  • ICE (Interactive Connectivity Establishment) is the framework that tries multiple connection paths and picks the best one.

NAT traversal is the hard problem WebRTC solves. Most devices sit behind routers that block incoming connections. ICE systematically tries connection methods—local network, STUN-discovered public addresses, and TURN relay—until something works.

Establishing a Peer Connection

The RTCPeerConnection API is your primary interface. Here’s how to create and configure one:

const configuration = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { 
      urls: 'turn:your-turn-server.com:3478',
      username: 'user',
      credential: 'password'
    }
  ]
};

const peerConnection = new RTCPeerConnection(configuration);

// Handle ICE candidates - send these to the remote peer via signaling
peerConnection.onicecandidate = (event) => {
  if (event.candidate) {
    signalingChannel.send({
      type: 'ice-candidate',
      candidate: event.candidate
    });
  }
};

// Handle connection state changes
peerConnection.onconnectionstatechange = () => {
  console.log('Connection state:', peerConnection.connectionState);
  if (peerConnection.connectionState === 'failed') {
    // Handle reconnection
  }
};

// Handle incoming tracks (remote video/audio)
peerConnection.ontrack = (event) => {
  remoteVideo.srcObject = event.streams[0];
};

The connection process follows an offer/answer model using SDP (Session Description Protocol):

  1. Caller creates an offer describing their capabilities
  2. Caller sets the offer as their local description
  3. Caller sends the offer to the callee via signaling
  4. Callee sets the received offer as their remote description
  5. Callee creates an answer
  6. Callee sets the answer as their local description
  7. Callee sends the answer back via signaling
  8. Caller sets the received answer as their remote description
// Caller side
async function createOffer() {
  const offer = await peerConnection.createOffer();
  await peerConnection.setLocalDescription(offer);
  signalingChannel.send({ type: 'offer', sdp: offer });
}

// Callee side
async function handleOffer(offer) {
  await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
  const answer = await peerConnection.createAnswer();
  await peerConnection.setLocalDescription(answer);
  signalingChannel.send({ type: 'answer', sdp: answer });
}

// Caller receives answer
async function handleAnswer(answer) {
  await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
}

Signaling Implementation

Here’s a minimal WebSocket signaling server in Node.js:

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

const rooms = new Map();

wss.on('connection', (ws) => {
  let currentRoom = null;
  let peerId = null;

  ws.on('message', (data) => {
    const message = JSON.parse(data);

    switch (message.type) {
      case 'join':
        peerId = message.peerId;
        currentRoom = message.room;
        
        if (!rooms.has(currentRoom)) {
          rooms.set(currentRoom, new Map());
        }
        rooms.get(currentRoom).set(peerId, ws);
        
        // Notify others in room
        broadcastToRoom(currentRoom, peerId, {
          type: 'peer-joined',
          peerId: peerId
        });
        break;

      case 'offer':
      case 'answer':
      case 'ice-candidate':
        // Forward to specific peer
        const targetPeer = rooms.get(currentRoom)?.get(message.target);
        if (targetPeer) {
          targetPeer.send(JSON.stringify({
            ...message,
            from: peerId
          }));
        }
        break;
    }
  });

  ws.on('close', () => {
    if (currentRoom && peerId) {
      rooms.get(currentRoom)?.delete(peerId);
      broadcastToRoom(currentRoom, peerId, {
        type: 'peer-left',
        peerId: peerId
      });
    }
  });
});

function broadcastToRoom(room, excludePeerId, message) {
  const peers = rooms.get(room);
  if (!peers) return;
  
  peers.forEach((ws, id) => {
    if (id !== excludePeerId) {
      ws.send(JSON.stringify(message));
    }
  });
}

Client-side signaling logic:

class SignalingChannel {
  constructor(serverUrl) {
    this.ws = new WebSocket(serverUrl);
    this.handlers = new Map();
    
    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      const handler = this.handlers.get(message.type);
      if (handler) handler(message);
    };
  }

  on(type, handler) {
    this.handlers.set(type, handler);
  }

  send(message) {
    this.ws.send(JSON.stringify(message));
  }

  join(room, peerId) {
    this.send({ type: 'join', room, peerId });
  }
}

Media Streams & Data Channels

Capturing media is straightforward with getUserMedia:

async function startVideoCall(peerConnection) {
  // Get local media
  const localStream = await navigator.mediaDevices.getUserMedia({
    video: { width: 1280, height: 720 },
    audio: true
  });
  
  // Display local video
  localVideo.srcObject = localStream;
  
  // Add tracks to peer connection
  localStream.getTracks().forEach(track => {
    peerConnection.addTrack(track, localStream);
  });
  
  return localStream;
}

// Screen sharing
async function shareScreen(peerConnection) {
  const screenStream = await navigator.mediaDevices.getDisplayMedia({
    video: { cursor: 'always' },
    audio: true
  });
  
  const videoTrack = screenStream.getVideoTracks()[0];
  const sender = peerConnection.getSenders()
    .find(s => s.track?.kind === 'video');
  
  if (sender) {
    await sender.replaceTrack(videoTrack);
  }
  
  return screenStream;
}

Data channels enable arbitrary data transfer with configurable reliability:

// Create data channel (caller side - before creating offer)
const dataChannel = peerConnection.createDataChannel('files', {
  ordered: true  // Use ordered: false for gaming/real-time apps
});

dataChannel.onopen = () => console.log('Data channel open');
dataChannel.onmessage = (event) => handleIncomingData(event.data);

// Receive data channel (callee side)
peerConnection.ondatachannel = (event) => {
  const channel = event.channel;
  channel.onmessage = (event) => handleIncomingData(event.data);
};

// Send a file
async function sendFile(file, dataChannel) {
  const chunkSize = 16384; // 16KB chunks
  const fileReader = new FileReader();
  let offset = 0;

  // Send metadata first
  dataChannel.send(JSON.stringify({
    type: 'file-start',
    name: file.name,
    size: file.size
  }));

  fileReader.onload = (e) => {
    dataChannel.send(e.target.result);
    offset += e.target.result.byteLength;
    
    if (offset < file.size) {
      readSlice(offset);
    } else {
      dataChannel.send(JSON.stringify({ type: 'file-end' }));
    }
  };

  const readSlice = (o) => {
    const slice = file.slice(o, o + chunkSize);
    fileReader.readAsArrayBuffer(slice);
  };

  readSlice(0);
}

Handling Real-World Challenges

Production WebRTC requires robust error handling:

class RobustPeerConnection {
  constructor(config, signalingChannel) {
    this.config = config;
    this.signaling = signalingChannel;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 3;
    this.createConnection();
  }

  createConnection() {
    this.pc = new RTCPeerConnection(this.config);
    
    this.pc.oniceconnectionstatechange = () => {
      const state = this.pc.iceConnectionState;
      console.log('ICE state:', state);
      
      if (state === 'failed') {
        this.handleConnectionFailure();
      } else if (state === 'disconnected') {
        // Wait briefly - might recover
        setTimeout(() => {
          if (this.pc.iceConnectionState === 'disconnected') {
            this.handleConnectionFailure();
          }
        }, 5000);
      } else if (state === 'connected') {
        this.reconnectAttempts = 0;
      }
    };
  }

  async handleConnectionFailure() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      this.onFatalError?.('Connection failed after max retries');
      return;
    }
    
    this.reconnectAttempts++;
    console.log(`Reconnect attempt ${this.reconnectAttempts}`);
    
    // ICE restart
    const offer = await this.pc.createOffer({ iceRestart: true });
    await this.pc.setLocalDescription(offer);
    this.signaling.send({ type: 'offer', sdp: offer, iceRestart: true });
  }
}

Security is largely handled for you—WebRTC mandates DTLS encryption for all connections. However, you must:

  • Always request user permission for media access
  • Validate signaling messages to prevent impersonation
  • Use secure WebSocket (wss://) for signaling
  • Implement authentication in your signaling server

Putting It Together: Mini Project

Here’s a complete video chat implementation:

class VideoChat {
  constructor(roomId, userId) {
    this.roomId = roomId;
    this.userId = userId;
    this.peers = new Map();
    
    this.signaling = new SignalingChannel('wss://your-server.com');
    this.setupSignaling();
  }

  setupSignaling() {
    this.signaling.on('peer-joined', async (msg) => {
      // New peer joined - create offer
      const pc = this.createPeerConnection(msg.peerId);
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      this.signaling.send({ 
        type: 'offer', 
        target: msg.peerId, 
        sdp: offer 
      });
    });

    this.signaling.on('offer', async (msg) => {
      const pc = this.createPeerConnection(msg.from);
      await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      this.signaling.send({ 
        type: 'answer', 
        target: msg.from, 
        sdp: answer 
      });
    });

    this.signaling.on('answer', async (msg) => {
      const pc = this.peers.get(msg.from);
      await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
    });

    this.signaling.on('ice-candidate', async (msg) => {
      const pc = this.peers.get(msg.from);
      await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
    });
  }

  createPeerConnection(peerId) {
    const pc = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
    });
    
    this.peers.set(peerId, pc);
    
    pc.onicecandidate = (e) => {
      if (e.candidate) {
        this.signaling.send({
          type: 'ice-candidate',
          target: peerId,
          candidate: e.candidate
        });
      }
    };

    pc.ontrack = (e) => {
      this.onRemoteStream?.(peerId, e.streams[0]);
    };

    // Add local tracks
    if (this.localStream) {
      this.localStream.getTracks().forEach(track => {
        pc.addTrack(track, this.localStream);
      });
    }

    return pc;
  }

  async start() {
    this.localStream = await navigator.mediaDevices.getUserMedia({
      video: true, audio: true
    });
    this.onLocalStream?.(this.localStream);
    this.signaling.join(this.roomId, this.userId);
  }
}

For deployment, use a managed TURN service (Twilio, Xirsys) rather than running your own—TURN servers consume significant bandwidth. Your signaling server scales horizontally with Redis pub/sub for multi-instance coordination.

WebRTC handles the hard parts of real-time communication. Your job is reliable signaling and graceful error recovery. Start simple, test across networks, and add TURN servers before users complain about connection failures.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.