Overview

I built a two-person video call feature last year. The expectation, from a product perspective, was "like Zoom but embedded in our app." The reality of WebRTC is that the hard part isn't the media — the browser handles that. It's the signaling, the NAT traversal, and the fact that the whole thing works differently depending on the network the two peers are on.

This is what I wish I'd known before starting.

The four pieces

Piece Purpose
Media capture getUserMedia() — access camera and mic
Peer connection RTCPeerConnection — the actual media transport
Signaling Exchange SDP and ICE candidates — you build this
Data channel RTCDataChannel — arbitrary data over the same connection

The signaling channel is the part people underestimate. WebRTC defines how peers talk to each other once connected, but says nothing about how they find each other. That's your problem, and you'll typically solve it with a WebSocket server that relays messages between the two peers until they're connected directly.

Capturing media

const stream = await navigator.mediaDevices.getUserMedia({
  video: { width: { ideal: 1280 }, height: { ideal: 720 } },
  audio: { echoCancellation: true, noiseSuppression: true },
});

// Attach to a video element
document.getElementById("local").srcObject = stream;

Three things worth knowing:

  • HTTPS is required for camera access, except on localhost. No exceptions.
  • The user must grant permission. There's no way around the prompt, and it can be permanently denied.
  • Constraints are a hint, not a guarantee. If the camera can't do 1280x720, you get something close. Use { exact: 1280 } to require it, with a fallback path.

The connection flow

// Caller creates the offer
const pc = new RTCPeerConnection({ iceServers: [...] });

// Add local media
stream.getTracks().forEach((track) => pc.addTrack(track, stream));

// Create and send offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: "offer", sdp: offer.sdp });

// Callee receives offer, creates answer
pc.ontrack = (event) => {
  document.getElementById("remote").srcObject = event.streams[0];
};

await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signaling.send({ type: "answer", sdp: answer.sdp });

// Caller receives answer
await pc.setRemoteDescription(answer);

That's the SDP exchange. But it doesn't work yet — the peers still need to find a network path to each other.

ICE: the hard part

ICE (Interactive Connectivity Establishment) is how two browsers figure out how to reach each other. There are three ways, tried in order of preference:

Type How it works When it works
Host Direct connection via local IP Same LAN only
Server reflexive (STUN) Discover your public IP, use NAT hole-punching Most home and office networks
Relay (TURN) All traffic goes through a relay server Symmetric NAT, corporate firewalls

STUN is cheap — a public server that tells you your IP. TURN is expensive — it relays every packet, which means bandwidth costs on the relay. About 15–20% of connections require TURN, but that percentage is higher in corporate networks.

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" },
    {
      urls: "turn:turn.example.com:3478",
      username: "user",
      credential: "pass",
    },
    {
      urls: "turns:turn.example.com:5349",
      username: "user",
      credential: "pass",
    },
  ],
});

The turns: entry is TURN over TLS, which is what you need for clients behind corporate firewalls that block UDP. If you want to support everyone, you need TURN, and TURN costs money.

Running TURN yourself with coturn is doable, but the bandwidth is real. A five-minute call at 1Mbps is 75MB, which doesn't sound like much until you have a thousand of them. Most production setups use a managed service — Twilio, Cloudflare Calls, or Metered — and eat the cost.

The ICE candidate exchange

pc.onicecandidate = (event) => {
  if (event.candidate) {
    signaling.send({ type: "candidate", candidate: event.candidate });
  }
};

// On receiving a candidate from the peer
signaling.on("candidate", async (candidate) => {
  await pc.addIceCandidate(candidate);
});

Candidates are trickled — sent as they're discovered, not batched. This speeds up connection time, but it means the signaling channel needs to handle them during the whole call setup period, not just the initial SDP exchange.

The connectionstatechange event tells you where things stand:

pc.onconnectionstatechange = () => {
  console.log("Connection state:", pc.connectionState);
  // new, connecting, connected, disconnected, failed, closed
};

pc.oniceconnectionstatechange = () => {
  console.log("ICE state:", pc.iceConnectionState);
  // new, checking, connected, completed, failed, disconnected, closed
};

When debugging, watch these. If you're stuck at checking forever, ICE is failing. If you reach connected and then drop, you probably have a network issue.

The WebSocket signaling server

// Node.js with ws
import { WebSocketServer } from "ws";

const rooms = new Map();

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws, req) => {
  const roomId = new URL(req.url, "http://localhost").searchParams.get("room");
  if (!roomId) return ws.close();

  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  const peers = rooms.get(roomId);

  if (peers.size >= 2) return ws.close(4000, "Room full");
  peers.add(ws);

  ws.on("message", (data) => {
    // forward to the other peer
    for (const peer of peers) {
      if (peer !== ws && peer.readyState === ws.OPEN) {
        peer.send(data);
      }
    }
  });

  ws.on("close", () => {
    peers.delete(ws);
    if (peers.size === 0) rooms.delete(roomId);
  });
});

This is the minimum. A production version needs authentication, room management, and handling for the case where a peer disconnects mid-call. But this is what a two-person call needs, and it's about 30 lines.

Data channels

The same peer connection carries arbitrary data, which is useful for chat, file transfer, and application state sync.

// Create on one side
const channel = pc.createDataChannel("chat", {
  ordered: true,        // guarantee ordering (default)
  maxRetransmits: 3,    // give up after 3 retries
});

channel.onopen = () => {
  channel.send(JSON.stringify({ type: "hello" }));
};

channel.onmessage = (event) => {
  const message = JSON.parse(event.data);
  console.log("Received:", message);
};

// On the other side, receive it
pc.ondatachannel = (event) => {
  const channel = event.channel;
  channel.onmessage = (event) => {
    // handle
  };
};
Setting Effect
ordered: true Messages arrive in order (TCP-like)
ordered: false Messages may arrive out of order (UDP-like)
maxRetransmits Retry limit before giving up
maxPacketLifeTime Time limit for retransmits

For real-time game state, use ordered: false — you care about the latest position, not the one from 200ms ago. For chat messages, use ordered: true. Don't mix them on the same channel.

STUN/TURN setup for production

For a small deployment, running coturn on a VPS:

# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
fingerprint
lt-cred-mech
realm=turn.example.com

# Static credentials for testing (use a Database or REST API for production)
user=testuser:testpassword

# TLS certs for turns:
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem

# Relay range
min-port=49152
max-port=65535

# External IP (needed when behind NAT)
external-ip=203.0.113.10

Open UDP 3478, TCP 5349, and UDP 49152–65535 in the firewall. The relay port range is the one people forget, and it's why TURN silently fails.

For dynamic credentials — which you want, because static credentials in client code are a security problem — coturn supports the --use-auth-secret mode with time-limited HMAC credentials. Your backend generates a credential per session and hands it to the client. The client uses it for the duration of the call.

What actually happens in the browser

A few things that will surprise anyone building this for the first time:

  • Media is encrypted by default. DTLS-SRTP handles it. You don't configure anything.
  • Both peers are "servers." Unlike a client-server model, both sides send and receive media symmetrically.
  • Screen sharing is a different API. getDisplayMedia() instead of getUserMedia(). Same track infrastructure.
  • Bandwidth adapts. WebRTC negotiates codecs and bitrates based on network conditions. You can influence this with setParameters() but usually shouldn't.
  • Stopping a track doesn't close the connection. You need to explicitly stop all tracks and close the peer connection.

Multi-party calls

Two-person calls use a single peer connection. Three or more changes the architecture:

Model How Best for
Mesh Every peer connects to every other 3–4 peers
SFU Each peer connects to a server that forwards streams 5+ peers
MCU Server mixes all streams into one Large calls, low client bandwidth

Mesh is trivial but doesn't scale — a 5-person call means each peer sends 4 streams, which is 4x the upstream bandwidth. SFU is the standard for anything beyond a handful of people, and it's what LiveKit, Janus, and mediasoup implement.

If you're building multi-party, use an SFU. Writing your own is a project, not a feature.

When not to use WebRTC

  • One-to-many broadcasting. WebRTC is optimized for interactive calls. For streaming to 1000 viewers, use HLS or DASH.
  • Simple data sync. If you just need to sync a document, WebSockets are simpler. WebRTC's data channel is for cases where P2P matters or where you need the media stack anyway.
  • Server-side processing only. If you're just recording audio server-side, plain HTTP upload is easier.

For two-person or small-group real-time communication, WebRTC is the only viable browser-native option. The setup is involved, but once it works it works, and the media quality is better than anything you can do with a custom protocol.

The debugging tool you'll use most

chrome://webrtc-internals is the single most useful tool for WebRTC. It shows every peer connection, every ICE candidate, the SDP exchange, bitrates, packet loss, and jitter in real time. When a call isn't working, this tells you why in seconds. Half the WebRTC problems I've debugged were visible here before I touched any code.