# How WebRTC Actually Works (From Scratch)

If you've ever wondered how Google Meet, Discord voice channels, or any browser-based video call actually moves audio and video between two people in real time, the answer is almost always WebRTC. It's one of those technologies that feels like magic until you look under the hood, and then it turns into a surprisingly logical stack of much older protocols wearing a modern JavaScript API as a trench coat. This post walks through WebRTC from first principles: what problem it actually solves, the three APIs that make it up, why signaling exists outside the spec, how two browsers behind different routers find each other, and what tends to break once you put this in front of real users.

## The problem WebRTC is solving

Browsers talk to servers over HTTP. That's a client-server model: your laptop asks, a server answers. It works fine for loading a webpage, but it's a bad fit for a video call, because routing every frame of video through a central server adds latency and cost that real-time communication can't afford. What you actually want for a call is a **direct connection between two browsers**, peer to peer. No middleman relaying every packet, just Browser A talking straight to Browser B. That sounds simple. It isn't, for two reasons:

1.  Browsers historically had no way to open a raw network socket to another browser. Everything went through the server.
    
2.  Even if they could, most devices sit behind NAT (network address translation), your home router, your college's network, your phone's carrier NAT, so your laptop doesn't have a public IP address that another peer could dial directly.
    

WebRTC solves problem one by giving browsers a native API for peer-to-peer media and data. It solves problem two with a set of protocols (STUN, TURN, ICE) borrowed from decades of VoIP engineering. Neither piece is new. WebRTC's real contribution is packaging all of it behind a JavaScript API that any browser can use without a plugin.

## The three APIs that make up WebRTC

When people say "WebRTC" they usually mean three separate browser APIs working together:

*   `MediaStream` **(getUserMedia)**: grabs audio/video from the camera and microphone.
    
*   `RTCPeerConnection`: the actual peer-to-peer pipe. Handles NAT traversal, encryption, and streaming media between browsers.
    
*   `RTCDataChannel`: a peer-to-peer channel for arbitrary data, not just media. This is what powers things like collaborative cursors, game state, or chat messages sent directly between peers instead of through a server.
    

`RTCPeerConnection` is the one doing the heavy lifting, and it's the part this post spends most of its time on.

## Signaling: the part WebRTC deliberately leaves out

Here's the detail that trips people up first: **WebRTC has no built-in way for two peers to find each other.** There's no "call this browser" function. Before any peer-to-peer connection can form, both sides need to exchange a bit of metadata, what codecs they support, their network addresses, session parameters. This exchange is called **signaling**, and the WebRTC spec intentionally says nothing about how you do it. Why leave it out? Because the two peers obviously can't talk directly yet, that's the whole problem being solved so the handshake has to go through some server they're both already connected to. But which server, over what protocol, is entirely up to you. Most implementations use a WebSocket server as a simple relay: both browsers connect to it, and it forwards signaling messages back and forth until the peers can talk directly.

![](https://cdn.hashnode.com/uploads/covers/68da758ae9bdc8d42e9efa04/c605bd73-8158-4650-8dad-88efb24f613e.png align="center")

Once that handshake finishes, the signaling server steps out of the way. It's not in the media path at all, it just introduced the two peers, like a matchmaker who leaves once the date starts.

## Why NAT makes this hard: STUN, TURN, and ICE

Say Browser A and Browser B want to connect directly. Neither one has a public IP. They're both behind NAT. A doesn't actually know its own public-facing address, and even if it did, B's router would drop an unsolicited incoming packet from a stranger. This is what **ICE (Interactive Connectivity Establishment)** is built to solve. ICE doesn't establish the connection itself, it's a framework for gathering every possible way two peers might reach each other, then testing all of them until one works. **STUN (Session Traversal Utilities for NAT)** is the simplest piece. A STUN server sits on the public internet with one job: when your browser sends it a packet, it replies with "here's the public IP and port I saw this packet come from." That's how your browser discovers its own public-facing address, since it can't know that on its own from behind a NAT. **TURN (Traversal Using Relays around NAT)** is the fallback for when direct connection just isn't possible like some NAT types (symmetric NAT, common on corporate and mobile networks) actively prevent it. A TURN server relays traffic between the two peers, acting as a proxy. It works reliably in every scenario, but it costs bandwidth on a server you have to run, so it's the last resort, not the first choice. So during connection setup, each browser gathers a list of **ICE candidates**, every address it might be reachable at:

*   **Host candidate**: its local network IP (useless across the internet, but cheap to try, and works if both peers happen to be on the same LAN)
    
*   **Server-reflexive candidate**: its public IP as seen by a STUN server
    
*   **Relay candidate**: a TURN server's address, as a last-resort fallback
    

Both peers exchange their full candidate lists via the signaling channel, then try connecting to each of the other side's candidates in parallel. Whichever pairing succeeds first wins, and that becomes the actual media path. This is why a `RTCPeerConnection` config always includes an `iceServers` list:

```js
const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" },
    {
      urls: "turn:your-turn-server.com:3478",
      username: "user",
      credential: "pass"
    }
  ]
});

```

## SDP: describing a session before it exists

Every offer and answer exchanged during signaling is written in **SDP (Session Description Protocol)**, a plain-text format describing what a peer wants to send and receive. It's older than WebRTC itself; SDP was originally designed for SIP-based VoIP. A trimmed-down SDP offer looks something like this:

```plaintext
v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
m=audio 9 UDP/TLS/RTP/SAVPF 111
a=mid:0
a=sendrecv
a=rtpmap:111 opus/48000/2
m=video 9 UDP/TLS/RTP/SAVPF 96
a=mid:1
a=sendrecv
a=rtpmap:96 VP8/90000

```

You'll basically never write SDP by hand. The browser generates and parses it for you, but knowing what's in there demystifies a lot. It's declaring: I have an audio track using the Opus codec, a video track using VP8, and I'm willing to both send and receive (`sendrecv`) on each. The exchange follows the **offer/answer model**: the caller creates an offer describing what it wants to send, the callee responds with an answer describing what it's willing to accept, and both sides apply each other's description as their "remote description." Only once both local and remote descriptions are set does the connection actually know what it's negotiating.

## The full connection flow, step by step

Putting the whole thing together, here's what actually happens between the moment you click "call" and the moment audio starts flowing:

1.  Browser A calls `getUserMedia()` to grab its camera/mic stream, and adds those tracks to a new `RTCPeerConnection`.
    
2.  Browser A calls `createOffer()`, then `setLocalDescription()` with that offer. This kicks off ICE candidate gathering in the background.
    
3.  The offer (SDP) is sent to Browser B through the signaling server.
    
4.  Browser B calls `setRemoteDescription()` with A's offer, grabs its own media, creates an `answer`, and calls `setLocalDescription()` with it.
    
5.  The answer travels back to A through signaling. A calls `setRemoteDescription()` with it.
    
6.  As ICE candidates are discovered on each side (host, STUN-reflexive, TURN-relay), they're sent to the other peer via signaling as soon as they're found. This happens asynchronously and in parallel with the SDP exchange, not after it.
    
7.  Each side tries connecting to the other's candidates. ICE picks the best working pair.
    
8.  Once a candidate pair connects, a **DTLS handshake** runs over that path to establish encryption keys.
    
9.  Media starts flowing as SRTP (encrypted RTP) directly between the two browsers. No server is in the loop anymore.
    

Steps 2 through 7 typically take a few hundred milliseconds to a couple of seconds, depending on network conditions and whether a TURN relay ends up being needed.

## Nothing here is sent in the clear

WebRTC mandates encryption. There's no way to opt out of it, unlike plain HTTP. Once ICE finds a working candidate pair, the two peers run a **DTLS (Datagram TLS)** handshake over it, which derives the keys used for **SRTP (Secure RTP)**, the protocol that actually carries encrypted audio/video packets. Data channels are secured with DTLS directly. This is one of the reasons WebRTC only works on HTTPS pages (or localhost). `getUserMedia` refuses to run in an insecure context.

## A minimal working example

Here's roughly what the client-side code looks like for a two-peer connection, using a WebSocket for signaling:

```javascript
const pc = new RTCPeerConnection({
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
});
const ws = new WebSocket("wss://your-signaling-server.com");

// Send our ICE candidates to the other peer as we discover them
pc.onicecandidate = (event) => {
  if (event.candidate) {
    ws.send(JSON.stringify({ type: "ice", candidate: event.candidate }));
  }
};

// Play remote media once it arrives
pc.ontrack = (event) => {
  remoteVideoEl.srcObject = event.streams[0];
};

// Grab local media and attach it
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));

// Caller side: create and send an offer
async function startCall() {
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  ws.send(JSON.stringify({ type: "offer", sdp: offer }));
}

// Handle incoming signaling messages
ws.onmessage = async (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === "offer") {
    await pc.setRemoteDescription(msg.sdp);
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    ws.send(JSON.stringify({ type: "answer", sdp: answer }));
  } else if (msg.type === "answer") {
    await pc.setRemoteDescription(msg.sdp);
  } else if (msg.type === "ice") {
    await pc.addIceCandidate(msg.candidate);
  }
};

```

That's a full 1-to-1 call in under 40 lines, once you have a signaling server relaying messages. Everything past that, reconnection handling, more than two peers, screen sharing, bandwidth adaptation, is where the real engineering work starts.

## Where this gets hard in production

The demo above works cleanly on a good network between two peers. Real deployments run into a different set of problems entirely: **Mesh doesn't scale.** A direct peer-to-peer connection between every pair of participants works fine for 2 people, gets rough at 4–5, and falls apart past that. Each peer has to upload their stream N-1 times, once per other participant. Beyond small group calls, you need an **SFU (Selective Forwarding Unit)**, a media server each peer connects to once, which forwards streams to everyone else. Large-scale calls (100+ participants) often go further, using an **MCU (Multipoint Control Unit)** that decodes and re-encodes streams server-side into a single composite feed. **Symmetric NAT forces you onto TURN, and TURN isn't free.** Corporate networks and some carrier-grade NATs assign a different external port for every destination a client talks to, which breaks the "guess the port" trick STUN relies on. When that happens, ICE falls back to relaying every packet through your TURN server, which means you're now paying real bandwidth costs for calls that "should" be peer-to-peer. Budgeting for TURN relay traffic is a real production line item, not an edge case. **Reconnection is genuinely the hardest part.** Networks change. Someone switches from wifi to cellular, a laptop sleeps and wakes, a router hiccups. `RTCPeerConnection` exposes connection state events (`iceconnectionstatechange`, `connectionstatechange`) but doesn't hand you a clean "resume" story. You have to build your own logic for detecting a dead connection, deciding when to renegotiate versus tear down and reconnect from scratch, and, if you're syncing any state alongside the media, reconciling what changed while the connection was down. **Codec and bandwidth negotiation matters more than it looks.** WebRTC does adaptive bitrate out of the box, dropping video quality under poor network conditions, but tuning it (simulcast, preferred codecs, bandwidth caps) is where a lot of the polish in production video apps actually lives.

## A note from actually building this

I built a real-time collaborative whiteboard (Scriblio) on a hybrid transport: WebRTC data channels for the direct peer-to-peer path, with WebSocket as a fallback for peers that can't establish a direct connection at all. Some symmetric NAT setups just won't cooperate no matter how much ICE negotiation you throw at them. Under load testing at 25 concurrent clients, median round-trip time came out to 107ms. The interesting engineering wasn't the happy path. It was handling the ICE connection state transitions cleanly and deciding, in code, exactly when a connection should be considered dead versus just slow, since getting that wrong either means giving up on a recoverable connection or leaving users stuck for way too long thinking they're still connected. That's usually how it goes with WebRTC: the API surface is small, the demo is deceptively easy, and the actual difficulty shows up entirely in the messy middle. NAT types you can't control, networks that change mid-call, and servers you have to run (STUN is cheap, TURN is not) just to guarantee the thing works for everyone, not just the lucky peers on friendly networks.

## Further reading

*   [webrtc.org](https://webrtc.org), the official spec and API reference
    
*   RFC 8825–8827, the core WebRTC IETF specs, if you want the source of truth
    
*   [webrtcforthecurious.com](https://webrtcforthecurious.com), a genuinely excellent free deep-dive book, goes far past what fits in a blog post
