Peer Pressure · Complete · 12 modules
Read 0%
Field guide · 12 modules · WebRTC

Peer
Pressure

Your browser can place an encrypted video call to another browser — no app, no plugin, and no server sitting in the middle of the media. That machine is called WebRTC, and it is one of the strangest, most over-engineered, most interesting pieces of the web platform. This doc rebuilds it from first principles: first the problem, then the predecessors, then every individual part, then the whole machine assembled.

Author · Noah
Style · BlockFrame
Status · Complete ✓ 12 / 12
01 · Where the problem comes from
The web's original sin

Before a single line of WebRTC makes sense, you need to feel the shape of the hole it fills. The hole is this: the web was built so that servers cannot speak first.

HTTP is a request–response protocol. Your browser opens a connection, asks one question ("GET /page"), the server gives one answer, and the exchange is over. The server is like a shop clerk who is physically incapable of phoning you — no matter what happens in the shop, the clerk can only speak when you walk in and ask. This wasn't an oversight. Statelessness is why the web scaled: any server can answer any request, answers can be cached anywhere, and no machine has to remember you between questions.

The price appears the moment information is born on the other side. A friend sends you a chat message. Their message reaches the server just fine — the server holds it, ready to deliver. But it has no way to reach you. Your browser sits behind an unlisted number. Until you ask, the news waits.

The one-way street, concretely
  • 1
    Client speaks first, always. Every HTTP exchange begins with the browser opening a connection. There is no packet a classic web server can send to a browser that isn't an answer to a question.
  • 2
    The connection is disposable. Once the response is delivered the exchange is done. Keep-alive reuses the socket for the next question, but it's still questions all the way down.
  • 3
    Events on the server are stranded. Chat messages, price ticks, a friend joining a call — anything that happens server-side has no path to a browser that isn't currently asking.

Everything in this module — polling, long-polling, SSE, WebSockets — is an increasingly clever answer to one question: how do you deliver server-born events to a client that must always speak first?

Hold onto this
HTTP made servers mute. Real-time on the web is the 30-year project of giving them a voice.
02 · Define the target before judging the fixes
The latency budget

"Real-time" sounds like a vibe. It's actually a number, and the number comes from human conversation. Telephone engineers measured this decades ago (it's codified in ITU-T G.114): when mouth-to-ear delay — the time from sound leaving my mouth to reaching your ear — stays under roughly 150 ms one way, conversation feels natural. Past ~250 ms, people start accidentally talking over each other. Past ~400 ms, turn-taking collapses and you're on a walkie-talkie.

Here's the part that makes it hard: the network is only one slice of that budget. Capturing audio, compressing it, buffering against jitter, decompressing, and playing it out all spend milliseconds before a single packet touches a wire. Drag the network slider and watch how little room there actually is.

Spend the 150 ms yourself

One-way network latency: 60 ms

Capture+Encode35 ms
Network60 ms
Jitter buffer40 ms
Decode+Play15 ms
150 ms · natural
400 ms · broken
Natural · 150 ms total

Fixed costs eat ~90 ms before the network gets a single millisecond. The wire's allowance is tiny.

Hold onto this
Real-time is a budget, not a vibe. From here on, every design choice gets judged in milliseconds.
03 · The pre-history
Teaching HTTP to push

For fifteen years the web faked server push with three escalating hacks, each one bending request–response a little further. They all still exist, they all still ship in production, and each one teaches you exactly what the next one had to fix. Watch the same scenario under each strategy: a client connects at t=0, and an event is born on the server at t=3.2s. How fast does it reach the client, and at what cost?

5
HTTP requests in 10 s
~3.5 KB
Header overhead
1.4 s
Event delivery lag
One-way*
Direction
Polling

Ask "anything new?" on a timer. Simple, works everywhere — but average lag is half your interval, and almost every request is wasted. Tight intervals hammer the server; loose ones feel dead.

Long-polling

Ask, and the server holds the question open until it has news. Lag drops to nearly zero — but every event still costs a full request cycle, headers and all, and one of the browser's ~6 connections per host stays hostage.

Server-sent events

One request opens a response that never ends; the server streams events down it. Cheap, auto-reconnecting, still plain HTTP — but strictly one-way and text-only. Client talk still needs separate requests.

Hold onto this
Each hack trades a different resource — lag, requests, or direction. None gives you a true two-way pipe.
04 · The real fix (for messages)
WebSockets: the honest fix

In 2011 the hacks were finally retired by an actual protocol. A WebSocket starts life as a normal HTTP request with a magic header — Upgrade: websocket — and if the server agrees (101 Switching Protocols), the two sides stop speaking HTTP entirely. The underlying TCP connection stays open, and both ends may now send frames — small binary or text messages — at any time, in either direction.

Everything the hacks bled for comes free. Server speaks first? Yes — it's a raw pipe. Overhead? A frame header is 2–14 bytes, versus ~700 bytes of HTTP headers per polled message. Lag? One network traversal, no request cycle. Chat, multiplayer lobbies, live dashboards, collaborative editors — from 2011 on, these are simply solved problems, and WebSockets remain the right tool for them today.

So why isn't a video call just a WebSocket?

Here's the setup for everything that follows. WebSockets give you a persistent, full-duplex, low-overhead message pipe — genuinely real-time by any messaging standard. And yet if you push live voice or video through one, quality collapses in ways no amount of clever engineering can fix. The reasons are structural, and there are exactly two.

Not a performance bug. Two load-bearing architectural walls. →

Hold onto this
WebSockets solved real-time messaging for good. Real-time media needs two things WebSockets structurally cannot give.
05 · Why WebRTC has to exist
The two walls
Wall 1 · Everything relays through your server

A WebSocket connects a browser to a server. Two browsers can't WebSocket each other — so in a "video call over WebSockets", every frame of video travels client → server → client. That costs three things: latency (the media takes a detour through wherever your server lives), money (every byte of every call crosses your bandwidth bill twice), and privacy (your server sees everything). Toggle the path:

~78 ms ~76 ms ALICE LONDON BOB PARIS YOUR SERVER VIRGINIA, USA
~154 ms
One-way media latency
~1.4 GB/h
Server bandwidth per call
Sees all
Server access to media

London → Virginia → Paris: two peers 340 km apart route their voices through a machine 6,000 km away. The budget from section 02 is already blown — before encoding spends a single millisecond.

Wall 2 · The TCP contract

WebSockets ride on TCP, and TCP makes a promise it will not break: every byte arrives, in order, or nothing after it is delivered. Lose one packet and TCP stops the line — data that already arrived sits in a buffer, undeliverable, while the sender retransmits the missing piece. That's head-of-line blocking, and for a file download it's exactly right.

For live audio it's exactly wrong. A retransmitted voice packet arrives ~a round-trip late — but the moment it described is gone. The jitter buffer played through it; the conversation moved on. You waited, stalled everything behind it, and received a packet that is now worse than useless. Live media wants the opposite contract: never wait for the past — a small glitch now beats a stall later. TCP cannot offer that, and a WebSocket cannot opt out of TCP.

So the requirements write themselves. Real-time media needs (1) a direct path between browsers — which means solving how two machines behind home routers find and reach each other; (2) a UDP-flavored transport that tolerates loss instead of stalling on it — which means media that can survive missing pieces; and (3) encryption without a trusted middleman, since no server sits in the media path to terminate TLS. That list — NAT traversal, loss-tolerant transport, peer-negotiated encryption, adaptive media — is WebRTC. Every module that follows is one item on this list.

Hold onto this
Wall 1: the relay tax. Wall 2: TCP's promise. WebRTC is what tunneling through both walls looks like.
06 · Before moving on
Check yourself

Answer out loud first. Then click to compare. If any of these feel shaky, re-read that section — module 2 builds directly on all three.

Q1Why can't a plain HTTP server just send your browser a message whenever it wants?
Click to reveal
Because there is nothing to send it to. HTTP connections are opened by the client, exist to carry one question and its answer, and the server has no way to initiate a connection back — your browser isn't listening for inbound connections, and (as module 3 will show) your home router would refuse them anyway. Server-side events are stranded until the client asks.
Q2Long-polling already delivers events with near-zero lag. Why did we still need WebSockets?
Click to reveal
Lag was never long-polling's real cost. Every delivered event still burns a full HTTP request cycle — ~700 bytes of headers each way, connection churn, and one of the browser's ~6 connections per host held hostage. And it's still half-duplex: the client can't talk on the held connection. WebSockets replace all of that with one persistent pipe, 2–14 byte frames, both directions, any time.
Q3Your WebSocket has a lovely 40 ms ping. Why does a voice call over it still fall apart at 2% packet loss?
Click to reveal
TCP. Every lost packet halts delivery of everything behind it until the retransmission arrives — so 2% loss doesn't mean "2% of audio glitches", it means the stream repeatedly stalls, latency spikes past the budget, and the jitter buffer keeps growing to cope. Worse, each retransmitted packet describes a moment that already passed. Live audio would rather drop that packet and glitch for 20 ms — a choice TCP never offers.
07 · The map
The road ahead

We ended module 1 with a requirements list: direct paths, loss-tolerant transport, peer-negotiated encryption, adaptive media. The next eight modules build each part in isolation; the last three bolt them together and scale them up.

Module 01 · Done
The real-time problem
Module 02 · Done
TCP vs UDP — the transport bargain
Module 03 · Done
The NAT problem
Module 04 · Done
ICE, STUN & TURN — hole punching
Module 05 · Done
Signaling & SDP — offer / answer
Module 06 · Done
DTLS & SRTP — securing the pipe
Module 07 · Done
The media pipeline — codecs & RTP
Module 08 · Done
Jitter, loss & congestion control
Module 09 · Done
Data channels — SCTP
Module 10 · Done
RTCPeerConnection — assembly
Module 11 · Done
Beyond two peers — mesh, SFU, MCU
Module 12 · Done — the finale
Capstone — build & debug a real call
Continue · Module 2
TCP vs UDP:
the transport bargain

We claimed TCP's promise ruins live audio and UDP's recklessness saves it. Module 2 earns that claim: what each protocol actually guarantees, what head-of-line blocking looks like packet by packet, and a torture test where the same voice stream survives UDP and chokes on TCP. Keep scrolling — it's written.

Module 2 · 01 · The fork in the stack
Two protocols, one wire

Module 1 ended by blaming "the TCP contract." To earn that, drop one level down. The internet's real delivery layer — IP — promises almost nothing: it moves individual packets best-effort. Packets can be dropped, reordered, duplicated, or delayed, and IP will not tell you. Every transport protocol is a decision about which of those indignities to fix, and at what price. The two answers that won are philosophical opposites:

TCPthe contract
  • A connection — handshake first, state on both ends, ordered byte stream.
  • Reliability — every byte sequence-numbered, ACKed, and retransmitted until it lands.
  • Strict ordering — the app receives bytes exactly in send order, or nothing at all.
  • Congestion control — senders probe for bandwidth and back off on loss, so the internet doesn't melt.
  • Price — 20+ byte headers, handshake round-trips, and the stall we're about to dissect.
UDPthe shrug
  • No connection — address a datagram, fling it. No handshake, no state.
  • No reliability — lost is lost. The sender is never told.
  • No ordering — datagrams arrive in whatever order the network felt like.
  • No congestion control — UDP will happily let you flood a link (this bill comes due in module 8).
  • Price of the shrug: 8 bytes. Ports and a checksum. UDP is barely more than IP with apartment numbers.

The instinct trained by twenty years of web development says TCP is "the good one" and UDP is the sketchy relic for DNS lookups. The whole point of this module is to break that instinct: neither protocol is better — they price risk differently, and live media is the rare workload where the shrug is worth more than the contract.

Hold onto this
IP promises nothing. TCP fixes everything and bills you in time. UDP fixes nothing and bills you 8 bytes.
Module 2 · 02 · The contract, mechanically
TCP's promise, packet by packet

TCP's reliability machine is beautiful: every byte is numbered, the receiver acknowledges what it has, the sender retransmits what's missing. But the promise it makes to your application is precise and merciless: you receive bytes in order, or you receive nothing. There is no API to say "give me what you've got." Step through what that means when one packet — just one — dies while five friends travel behind it:

On the wire
Receiver's TCP buffer — arrived, but hostage to order
Delivered to the app — what you actually hear
Hold onto this
Head-of-line blocking: one lost packet takes every packet behind it hostage. The network lost 20 ms of audio; the contract turned it into 200+.
Module 2 · 03 · The conversion
On TCP, loss becomes latency

Put numbers on the step-through. Detecting a loss takes time: with fast retransmit the sender needs three duplicate ACKs — roughly one round trip after the loss — and if traffic is too sparse for dup-ACKs, a retransmission timeout fires instead, floored around 200 ms on Linux and often far worse. So every loss stalls the stream for ≈ RTT at best, ≈ RTO at worst — and during the stall, arriving packets pile up undeliverable behind the hole.

Now the killer accounting move. A live receiver has two choices during a stall: freeze (audio stops mid-word) or fall behind (grow the jitter buffer and stay permanently later than live). Either way, the loss was never really "repaired" — it was converted into latency, the one currency module 1 proved you cannot afford. And TCP does it again on every single loss, forever. UDP makes the opposite trade: the lost 20 ms frame is simply gone — the decoder conceals the gap, the stream never stops, and the latency budget is untouched.

"Late is worse than lost. TCP refuses to lose, so it is always late."
The entire transport argument of WebRTC, one sentence
Hold onto this
TCP converts loss into delay. UDP keeps loss as loss. For live media, a 20 ms hole beats a 200 ms freeze — every time.
Module 2 · 04 · See it, don't take my word
The torture test

The same 10 seconds of voice — 500 frames, 20 ms each — sent over both transports through the same lossy network. Set the packet loss and the round-trip time, and compare what actually reaches your ear. On the UDP row, a lost frame is a pink blip the decoder papers over. On the TCP row, every loss inserts a black stall (≈ RTT + one frame to detect and repair) — and pushes everything after it further behind live.

Packet loss · 2%
Round-trip time · 200 ms
played on time lost → 20 ms glitch (UDP) frozen, waiting for retransmit (TCP) played late, after repair (TCP)
10
frames lost (both rows)
0.2 s
UDP · total glitch, concealed
2.2 s
TCP · total frozen time
2.2 s
TCP · behind live after 10 s

Hold onto this
Same network, same losses. UDP degrades gracefully by milliseconds; TCP degrades catastrophically by seconds.
Module 2 · 05 · Reading the fine print
The bargain WebRTC takes

So UDP wins for live media — but be honest about what was signed away, because the rest of this course is the story of paying it back. Choosing UDP means volunteering to rebuild, in userland, every TCP feature you still need — and only those:

  • 1
    Ordering & loss visibility → RTP. Media packets get sequence numbers and timestamps so the receiver can reorder what's salvageable and know what's gone — without ever stalling for it. (Module 7.)
  • 2
    Politeness → congestion control, rebuilt. UDP lets you flood a link and ruin the network for everyone — so WebRTC ships its own bandwidth estimation and adapts bitrate in real time. The contract's one genuinely public duty, reimplemented. (Module 8.)
  • 3
    Reliability à la carte → SCTP data channels. Some data (chat, file chunks) really does need TCP-like guarantees. WebRTC runs SCTP over UDP so each channel chooses: reliable or lossy, ordered or unordered. Reliability becomes a dial, not a dogma. (Module 9.)
  • 4
    And two problems get harder. No connection means NATs and firewalls treat UDP with suspicion — reaching a peer becomes a project (module 3). And with no TLS-over-TCP to lean on, encryption needs its own machinery: DTLS. (Module 6.)

That list — RTP, congestion control, SCTP, NAT traversal, DTLS — should look familiar: it's the parts list from module 1, now with reasons. WebRTC isn't a pile of acronyms; it's the itemized bill for choosing the 8-byte shrug over the contract.

Hold onto this
WebRTC = UDP plus exactly the parts of TCP worth keeping, rebuilt where the app can control them.
Module 2 · 06 · Before moving on
Check yourself

Out loud first, then click. Module 3 assumes these are solid.

Q1TCP never loses data. So why does a voice call over TCP freeze instead of glitch?
Click to reveal
Because "never loses" is enforced by in-order delivery. When a packet dies, everything behind it sits in the receiver's buffer, undeliverable, until the retransmit arrives — roughly one RTT later at best, an RTO (200 ms+) at worst. The app hears nothing during that window: a freeze. The data all arrives eventually, but for live audio "eventually" is the same as never — the moment it described has passed.
Q2A call over UDP is dropping 2% of its packets. Why does it still sound fine?
Click to reveal
Each lost packet costs exactly one 20 ms frame — nothing else waits for it. The decoder's packet-loss concealment synthesizes a plausible 20 ms from the surrounding audio, and isolated gaps that short are barely perceptible. Crucially, loss never converts into latency: the jitter buffer stays small, the stream stays live, and 2% loss stays 2% loss instead of becoming seconds of freeze.
Q3If UDP is the answer, why isn't WebRTC just "media over raw UDP" — what three things must be rebuilt on top?
Click to reveal
First, sequencing and timing — RTP's sequence numbers and timestamps, so the receiver can reorder and detect loss without stalling. Second, congestion control — UDP has no politeness, so WebRTC must estimate bandwidth and adapt bitrate itself or it becomes a network menace. Third, security — no TCP means no TLS, so DTLS provides the handshake and keys. (And before any of that works, you have to reach the peer at all — which is module 3's NAT problem.)
Next up · Module 3
The NAT problem:
why peers can't find each other

We've picked UDP and promised a direct path — but your laptop doesn't have a public address, and neither does theirs. Module 3 opens the box on NAT: why it exists, the four flavors, and why two well-meaning routers make "just send a packet" one of networking's hardest tricks. Keep scrolling — it's written.

Module 3 · 01 · Why any of this exists
The address shortage

Module 2 ended with a promise and a debt: UDP gives us the right transport, if we can get a packet from one browser to the other. Here's why we mostly can't. An IPv4 address is 32 bits — about 4.3 billion possible addresses, minus large reserved chunks. That looked infinite in 1981. Then humanity connected tens of billions of devices.

The fix that actually shipped (while IPv6 was busy being the future) was a pragmatic cheat: stop giving every device a real address. RFC 1918 set aside three private ranges — 10.0.0.0/8 · 172.16.0.0/12 · 192.168.0.0/16 — that anyone may use, that mean nothing on the public internet, and that millions of homes reuse simultaneously. Your whole household shares one public address, and a box in the closet — the NAT (Network Address Translator) — forwards traffic by rewriting every packet that crosses it.

2³²
≈ 4.3 B IPv4 addresses, ever
3
private ranges, reused by every home on earth
1
public IP for your laptop, phone, TV & fridge
0
of your devices reachable from outside, by default

NAT saved the IPv4 internet — and quietly rewrote its social contract. The original design said any host can send a packet to any other host. After NAT, consumer devices became clients only: able to speak first, never to be spoken to. That asymmetry is fine for loading pages. It is fatal for "browser calls browser."

Hold onto this
NAT solved "not enough addresses" by demoting your devices from hosts to clients. WebRTC's first fight is winning host-hood back.
Module 3 · 02 · The machine itself
The rewrite, packet by packet

A NAT is not magic — it's a table and a pen. For every outbound packet it picks a free public port, writes a row mapping inside address:port ⇄ public port, and rewrites the packet on the way through. Everything WebRTC fights in the next module falls out of three properties of that table. Watch one mapping live and die:

On the wire
The NAT's translation table · public side: 86.20.11.4
Hold onto this
Mappings are born on outbound only, they expire in seconds of silence, and they filter who may answer. All three rules exist to be fought in module 4.
Module 3 · 03 · Know your enemy
The four flavors of NAT

Not all NATs are equally hostile. The classic taxonomy asks two questions: does the same inside socket get the same public port for every destination (mapping), and who is allowed to send in through it (filtering)? Fix the scenario: your laptop (:52001) has sent exactly one packet, to Server A:3478. Now try three inbound senders under each flavor:

the mapping(s) this NAT creates
who gets in — inbound to :41822

(Modern specs — RFC 4787 — describe the same space as endpoint-independent vs endpoint-dependent mapping and filtering; the four "cone/symmetric" names are the field slang that stuck. The one that matters is the last: symmetric mapping is the hole-punch killer, because the port your peer learns is not the port they need.)

Hold onto this
Two dials: does the port stay the same across destinations, and who may send in. Cones are punchable. Symmetric means a relay.
Module 3 · 04 · The impasse
Two NATs, zero listeners

Now put a NAT on both ends — the normal case — and feel the deadlock. Alice can't send to Bob: Bob's mapping doesn't exist (mappings are born on outbound), and even if it did, she doesn't know its public port. Bob is in exactly the same trap. Each side is waiting for the other to become reachable first. It's module 1's "servers can't speak first" problem again — except now nobody can speak first.

"Two polite doormen, each refusing every guest the other hasn't announced."
The NAT impasse, and why a call can't start by itself

The escape is a genuinely beautiful trick, and module 4 builds it properly — but here's the shape. A third machine both peers can reach tells each one the other's public ip:port guess. Then both fire packets at each other at the same time. Each NAT sees its own side's outbound packet first — row written, filter satisfied — so when the other side's packet arrives moments later, it finds a warm mapping and walks straight in. Both doormen think their own resident invited the guest. That's hole punching: it works on cone NATs, and dies on symmetric ones — which is why a relay (TURN) must always be standing by.

~10–15%
of real-world pairs can't punch through and fall back to a relay
×2
CGNAT: your "public" address is often another carrier NAT (100.64/10)
IPv6
kills NAT, keeps the rule — stateful firewalls still drop the uninvited
Hold onto this
Neither peer can speak first — so both speak at once, and each NAT believes it started it. Everything in module 4 exists to arrange that moment.
Module 3 · 05 · Before moving on
Check yourself

Out loud first, then click. Module 4 stands entirely on these three.

Q1Why does an unsolicited inbound packet get dropped instead of just… creating a mapping?
Click to reveal
Because the NAT literally cannot fill in the row. A mapping binds a public port to an inside address:port — and an uninvited packet carries no clue which of the thirty devices sharing the public IP it should go to, let alone which port on that device. Only an outbound packet teaches the NAT the inside half of the binding. (And routers lean into it: default-drop doubles as the home firewall.)
Q2A call's audio dies about a minute in, every time, on Wi-Fi that otherwise works fine. What's your NAT-shaped suspicion?
Click to reveal
The UDP mapping expired. NAT rows live on traffic — go quiet for tens of seconds (RFC 4787 asks routers to allow at least two minutes; plenty are meaner) and the row is erased, after which the peer's media hits a closed door and is silently dropped. This is exactly why WebRTC sends small STUN keepalives every ~15–25 seconds for the entire life of a call: not to say anything, just to keep the row warm.
Q3Why is symmetric NAT the hole-punching killer, when port-restricted NAT has equally strict filtering?
Click to reveal
Filtering isn't the fatal part — the mapping is. On a port-restricted NAT your public port is the same for every destination, so the port a helper server observed is the port your peer should aim at; simultaneous sends satisfy the filter and the punch lands. On a symmetric NAT, the mapping the helper saw was created for the helper — talking to your peer allocates a brand-new port the peer has never heard of. They're aiming at a hole that only exists for someone else. Guessing ports occasionally works; mostly, you relay.
Next up · Module 4
ICE, STUN & TURN:
hole punching, industrialized

We know the trick: learn your own public address, trade it through a mutual friend, fire simultaneously. Module 4 turns that trick into the machine WebRTC actually runs — STUN mirrors, TURN relays, candidate gathering, and ICE's tournament that races every possible path and keeps the best one standing. Keep scrolling — it's written.

Module 4 · 01 · Step one: find your own face
STUN: the mirror

The escape plan from module 3 starts with an embarrassing prerequisite: before you can tell anyone your public address, you have to learn it yourself — your laptop genuinely doesn't know. It knows 192.168.1.7; the public face is something the NAT invents on the fly, per socket.

STUN (Session Traversal Utilities for NAT) is the smallest possible fix: a public server that works as a mirror. From the exact socket you'll use for the call, you send a ~20-byte binding request; the server replies with one fact — "here's the address and port I saw you as." That answer is your server-reflexive address: the public identity your NAT minted for this socket. STUN is so cheap that public servers are free infrastructure (every WebRTC tutorial's stun:stun.l.google.com:19302).

A detail too good to skip: the reply hides your address by XOR-ing it with a magic cookie (XOR-MAPPED-ADDRESS). Why? Because some NATs deep-inspect packets and helpfully rewrite anything that looks like an IP address in the payload — mangling the one fact the whole protocol exists to deliver. The mirror has to smuggle your own reflection past your own router.

And remember module 3's fine print, because it's now load-bearing: what STUN really told you is the mapping for the path to the STUN server. On any cone NAT, that same port serves every destination — the reflection is honest. On a symmetric NAT, it's already a lie for anyone else. ICE is designed around not trusting this answer.

Hold onto this
STUN answers one question — "what do I look like from outside?" — for free, from the socket that matters. Whether the answer holds for your peer depends on the NAT.
Module 4 · 02 · Step two: buy insurance
TURN: the fallback you rent

Module 3 promised that when hole punching fails — symmetric NATs, hostile firewalls, UDP blocked outright — there must be a relay standing by. TURN (Traversal Using Relays around NAT) is that relay, formalized: you authenticate to a TURN server and it allocates you a public address on itself. Anything your peer sends to that address, the server forwards down your already-open outbound connection — the one thing every NAT permits.

Wait — isn't this exactly module 1's "relay tax," the wall we built WebRTC to avoid? Yes, and the difference is the terms. The relay is no longer the architecture; it's the last resort, used only when the tournament proves nothing direct works. It carries one call's media, not every call's. You choose where it runs (close to users, not wherever your web server lives). And unlike STUN's one-packet favor, TURN moves your actual media — which is why it's the expensive line in every WebRTC infra bill, why it requires credentials, and why providers meter it per gigabyte.

1 pkt
STUN's job — a mirror glance, basically free
every pkt
TURN's job — your whole stream, metered per GB
~10–15%
of calls end up needing it — 100% must have it ready
Hold onto this
TURN is the relay tax renegotiated: paid rarely, on your terms, instead of always, by design. STUN asks the question; TURN guarantees an answer.
Module 4 · 03 · Step three: collect your identities
Candidates: every self you might be

Now the philosophical move that makes ICE work: stop trying to figure out which address is "right." You can't know in advance — it depends on the peer's network, your NAT's flavor, and luck. So WebRTC gathers every address you might be reachable at — each one a candidate — and lets reality sort them out. Gather yours:

the browser does this the moment you create an RTCPeerConnection

Each candidate carries a priority — a single number encoding the preference order you'd expect: host beats srflx beats relay, because a LAN path beats a punched path beats a paid path. And candidates don't wait for each other: the moment one is discovered it's shipped to the peer (trickle ICE), so checking can begin while gathering is still running. Every millisecond here is call-setup time a human is staring at.

Hold onto this
Don't guess the right address — enumerate all of them, priced by how much you'd prefer each. Reality gets the final vote.
Module 4 · 04 · Step four: race everything
The tournament

Both sides now hold two candidate lists — their own and their peer's (traded through the mutual friend module 5 will finally introduce). ICE crosses them into candidate pairs, sorts by priority, and then does the beautifully dumb thing: tries them all. Each "try" is a STUN binding request fired directly at the peer — and here's the payoff of module 3: because both sides run checks at once, the checks themselves are the simultaneous sends. The probe is the hole punch. Pick a scenario:

Details worth keeping: checks are authenticated (each side's SDP carries a username fragment and password, so a stranger can't hijack the race). A check arriving from an address you'd never heard of births a peer-reflexive candidate mid-tournament — sometimes rescuing NATs the theory said were hopeless. The winning pair gets nominated by the controlling side and becomes the path; the others are abandoned. And the tournament never really ends — keepalives guard the path, and if it dies mid-call, an ICE restart runs the whole race again without dropping the session.

"ICE doesn't pick the best path. It races all of them and keeps the survivor."
Robustness by exhaustion — the only strategy NATs can't surprise
Hold onto this
Pair everything, check everything, nominate the survivor. The connectivity check doubles as the hole punch — the race is the trick.
Module 4 · 05 · Before moving on
Check yourself

Out loud first, then click. Module 5 finally meets the "mutual friend" — make sure you know why it's needed.

Q1STUN just told you your public ip:port. Under which NAT is that answer already useless for your peer — and why is it fine for the others?
Click to reveal
Symmetric. The reflexive address is the mapping your NAT created for the path to the STUN server — and a symmetric NAT mints a different public port per destination, so the port your peer aims at simply doesn't exist for them. On any cone NAT the mapping is endpoint-independent — one public port for all destinations — so the mirror's answer holds for everyone, and a punched direct path is on the table.
Q2~85–90% of calls never touch the relay. Why does WebRTC allocate TURN up front anyway, instead of only when punching fails?
Click to reveal
Because you can't know punching failed until the tournament runs — and the tournament needs relay pairs in the race to have a guaranteed finisher. Allocating after failure would serialize the two slowest steps and add seconds to exactly the calls already having the worst day. An unused allocation costs almost nothing; a call that spins forever costs the user. TURN is the floor under the race: worst case still connects.
Q3Why are ICE's connectivity checks STUN packets, rather than any old ping?
Click to reveal
Three jobs in one packet. The check is the punch: an outbound STUN request writes the NAT mapping and whitelists the peer, and the response proves the path works both ways. It authenticates: checks carry the ufrag/password from signaling, so only your actual peer can win the race. And it discovers: the response reports the address the check arrived from, birthing peer-reflexive candidates — STUN's mirror trick, reused mid-tournament on the real path.
Next up · Module 5
Signaling & SDP:
the offer/answer dance

Five times now we've said "traded through a mutual friend." Module 5 meets the friend — and reads the letters. Why WebRTC deliberately ships no signaling protocol, what an SDP offer actually says line by line, and how candidates, credentials, and codecs all hitch a ride on the same handshake. Keep scrolling — it's written.

Module 5 · 01 · The missing chapter
The deliberate hole in the spec

Count what modules 3–4 quietly assumed: peers "trade candidate lists", checks carry "credentials from signaling", a helper "tells each side the other's address". All of that must travel between two browsers that — by the entire premise of this course — cannot yet reach each other. You need a channel to build the channel. So some ordinary, server-relayed channel is unavoidable, and it's called signaling.

Here's the twist: WebRTC refuses to standardize it. The spec defines everything about the peer connection and nothing about how offers travel. Not an oversight — a design decision. Whatever you already have works: a WebSocket, HTTP polling, a SIP trunk, an XMPP server, a QR code, literally copy-paste over email. Existing telephony systems keep their signaling and bolt WebRTC on as the media engine; new apps use whatever their stack already speaks.

And notice who's perfect for the job: module 1's WebSocket, finding its true calling. Signaling messages are tiny, rare, and genuinely need reliable ordered delivery — the exact workload TCP is right for. The two walls never applied here: nobody cares that a 2 KB offer relays through a server with 40 ms of latency. The walls only ever applied to media. Right tool, right layer.

Hold onto this
Signaling is the channel you build the channel with — server-relayed, TCP, totally fine. WebRTC standardizes the letters, never the postman.
Module 5 · 02 · The letters themselves
SDP: reading the letter

What travels over signaling is a Session Description — everything the other side needs to receive your media: codec menu, ICE credentials, candidates, crypto fingerprint. The format, SDP, is a line-oriented text relic of the mid-90s multicast era that WebRTC inherited from the telephony world — ugly, ancient, and carrying the entire handshake. When you call createOffer(), this is what comes out. Click any line:

click a lineevery highlighted line has a story — most of them connect to a module you've already read.

Notice the pattern in the annotations: the ICE credentials are module 4's tournament passwords, the fingerprint is module 6's anchor, the FEC knob is module 8 waving hello, and the addresses are deliberately useless because the candidates carry the truth. SDP isn't a config file — it's the rendezvous document where every part of WebRTC files its paperwork.

Hold onto this
One ugly text blob carries the codec menu, the tournament credentials, and the crypto anchor. Learn to read it and every WebRTC bug report gets easier.
Module 5 · 03 · Offer, answer, trickle
The dance

SDP travels in a strict two-beat rhythm: one side writes an offer, the other replies with an answer, and nothing is negotiable outside that exchange. The browser enforces it as a state machine — stable → have-local-offer → stable — and candidates trickle around the dance the whole time. Step through a real call setup:

Alice's browser
The signaling server — a dumb WebSocket forwarder
Bob's browser

One classic failure worth naming: glare — both sides create offers at the same instant, and neither state machine can accept a remote offer while holding its own. The standard cure is perfect negotiation: assign one peer the "polite" role up front; on collision the polite peer rolls its offer back and accepts the other's, the impolite one ignores the incoming offer entirely. A coin flip, agreed before the argument starts.

Hold onto this
Offer, answer, trickle — two letters and a stream of postcards, all through a dumb forwarder. After that, the server never sees another byte.
Module 5 · 04 · Before moving on
Check yourself

Out loud first, then click. Module 6 leans hard on one line of the SDP — make sure you know which.

Q1Why did WebRTC — a spec that standardizes everything down to packet formats — deliberately refuse to standardize signaling?
Click to reveal
Because a server-relayed channel is unavoidable anyway (no P2P path exists yet), and every deployer already has one: telephony systems speak SIP, chat systems speak XMPP, web apps have WebSockets. Standardizing one blessed protocol would have made WebRTC a walled garden and broken interop with the entire existing communications world. The spec standardizes the content (SDP semantics, the state machine) and leaves the transport to whoever's building — signaling is small, rare, TCP-friendly traffic where module 1's walls simply don't apply.
Q2Name three things the offer must deliver before module 4's tournament can legally begin.
Click to reveal
The ICE credentials (a=ice-ufrag / a=ice-pwd) — without them no connectivity check can be authenticated, so every probe would be dropped as a stranger's packet. The candidates (in the SDP or trickled alongside) — without addresses there are no pairs to race. And the DTLS fingerprint (a=fingerprint) — not needed to race, but the race is pointless if the winner can't be secured; it must arrive over signaling because it's the trust anchor for module 6. Plus, implicitly: the media sections that tell both sides what they're even negotiating.
Q3Both peers hit "call" simultaneously and each fires an offer. What breaks, and how does perfect negotiation fix it?
Click to reveal
Glare. Each browser is in have-local-offer, and the state machine forbids applying a remote offer from that state — both calls would error and the session would deadlock. Perfect negotiation assigns roles before the collision: the polite peer, on seeing an incoming offer mid-offer, rolls back its own (setLocalDescription({type:"rollback"})) and answers; the impolite peer simply ignores the incoming offer and waits for its answer. Deterministic, no timing luck involved.
Next up · Module 6
DTLS & SRTP:
securing a pipe with no middleman

The tournament found a path and signaling delivered a fingerprint. Module 6 spends both: how two strangers run a TLS-style handshake over raw UDP, why the fingerprint in the SDP is the entire trust story, and how SRTP encrypts media without adding latency. Keep scrolling — it's written.

Module 6 · 01 · Why HTTPS's playbook doesn't apply
The wrong assumptions

The tournament found a path; signaling delivered a fingerprint. Now the path must be encrypted — and in WebRTC that's not a feature, it's the law: there is no unencrypted mode. The spec refuses to even negotiate one. So how? The web already has TLS — but look at what TLS quietly assumes, and watch every assumption fail:

  • 1
    TLS assumes TCP. Its handshake is a conversation over a reliable, ordered stream — lose one handshake packet and classic TLS just waits forever. We spent module 2 choosing UDP precisely to escape that stream. Something TLS-shaped must survive datagrams.
  • 2
    TLS assumes certificates from authorities. HTTPS works because a CA vouched that this key belongs to that domain name. Your laptop has no domain name. No CA will ever issue a certificate for "Alice's browser, Tuesday, tab 3." Identity has to come from somewhere else.
  • 3
    TLS assumes a server. One side is the well-known, certificate-bearing establishment; the other is an anonymous visitor. A call has two anonymous visitors and — by module 1's whole thesis — no establishment in the middle to terminate anything.

WebRTC's answer is a matched pair: DTLS — TLS re-engineered to survive datagrams — for the handshake and keys, plus the fingerprint trick from module 5's SDP to replace the certificate authority. And then a twist: the handshake's real product isn't a tunnel, it's keys — which get handed to SRTP, a cheaper armor purpose-built for media.

Hold onto this
No TCP, no CA, no server — every pillar of HTTPS is missing. DTLS rebuilds the handshake; the SDP fingerprint rebuilds the trust; SRTP armors the media.
Module 6 · 02 · The handshake, weatherproofed
DTLS: TLS for a lossy world

DTLS (Datagram TLS) is not a new protocol — it's TLS with storm shutters. Same cipher suites, same ECDHE key exchange (fresh keys per call: forward secrecy), same ClientHello → certificates → Finished choreography, with the roles assigned by module 5's a=setup line (the answerer usually goes active and dials as the client). What's new is exactly the set of patches UDP forces. Break TLS three ways and watch DTLS cope:

Plain TLS on UDPdies
DTLScopes

Cost of the whole ceremony: one or two round trips, once per connection — it runs the instant module 4 nominates a pair, before the first media packet. After that, DTLS mostly steps aside: media won't travel inside DTLS records at all. It gets something lighter.

Hold onto this
DTLS = TLS + retransmission timers + message numbering + fragmentation + self-contained records. Same trust math, weatherproofed for module 2's chosen transport.
Module 6 · 03 · Trust, without an authority
The fingerprint trick

With no CA, each browser simply mints its own certificate — self-signed, ephemeral, vouched for by nobody. Worthless, except for one move: module 5's SDP carried a=fingerprint, a SHA-256 hash of that certificate, over the signaling channel. When the DTLS handshake runs on the direct path, each browser checks: does the certificate my peer just presented hash to the fingerprint signaling promised? Match → the entity on this path is provably the one the friend introduced. Mismatch → hang up, loudly. Now attack it:

That second tab is the honest boundary of the guarantee, and it's worth saying plainly: WebRTC is end-to-end encrypted up to the honesty of your signaling channel. In practice signaling rides HTTPS/WSS behind your app's login — so the CA system didn't vanish, it moved up a layer and vouches for the introducer instead of the peers. Apps that can't trust even their own server add out-of-band verification on top: short authentication strings you read aloud, Signal-style safety numbers — humans doing the fingerprint comparison themselves.

Hold onto this
Self-signed certs + a hash carried by signaling = authentication without a CA. The guarantee is exactly as strong as the channel that carried the hash.
Module 6 · 04 · The armor that weighs nothing
SRTP: armor without weight

Here's the elegant swerve: after all that ceremony, media never travels inside DTLS. The handshake's true product is a set of keys, exported and handed to SRTP — Secure RTP — which encrypts each media packet individually. Why the handoff? Because a generic secure tunnel is the wrong shape for module 2's philosophy. SRTP is encryption that took the transport bargain seriously:

  • 1
    Every packet stands alone. Each SRTP packet decrypts independently — a lost packet ruins nothing but itself, exactly like the transport it rides. No stream state to resynchronize, no stall. Module 2's freshness rule, honored by the crypto layer.
  • 2
    Headers stay readable; payload doesn't. SRTP encrypts the media payload but leaves the RTP header — sequence number, timestamp, stream id — in the clear, authenticated but visible. The jitter buffer must reorder and detect loss before decrypting; stats need timings; and module 11's media servers will need to route packets they can't decrypt. Nobody can forge a header (the auth tag covers it) — but observers can see the rhythm of the call.
  • 3
    Per-packet cost, near zero. AES with a ~4–16 byte auth tag per packet, no handshakes after the first, no added round trips ever. The latency budget from module 1 doesn't feel it.

One asterisk for later: module 9's data channels take the other road — SCTP really does run inside DTLS records, because data isn't loss-tolerant media. Two cargoes, two vehicles, one handshake. And the metadata leak in point 2 is real: even with perfect encryption, packet timing reveals who's speaking when. Encryption hides words, not rhythm.

1–2 RTT
handshake cost — paid once, before first media
+0 ms
added latency per media packet, forever after
100%
of WebRTC traffic encrypted — no opt-out exists
Hold onto this
DTLS makes the keys; SRTP spends them — per packet, loss-tolerant, latency-free. Media gets armor shaped exactly like the transport bargain.
Module 6 · 05 · Before moving on
Check yourself

Out loud first, then click. The pipe is now found, negotiated, and armored — module 7 finally puts media in it.

Q1Why can't the call just use regular TLS, like every HTTPS request does?
Click to reveal
TLS assumes the reliable, ordered stream that TCP provides — and module 2 deliberately walked away from TCP. On raw UDP a lost ClientHello would hang the handshake forever, reordered flights would desynchronize it, and an oversized certificate would exceed what one datagram can carry. DTLS is TLS plus exactly the missing machinery: retransmission timers, message sequence numbers, explicit fragmentation, and records that each decrypt independently.
Q2No authority ever issued your browser a certificate. What, precisely, does the DTLS handshake authenticate — and what does it not?
Click to reveal
It proves: the entity on this direct path owns the private key of the certificate whose hash arrived over signaling. In other words — "this is really the peer the friend introduced." It does NOT prove "this is Alice" in any global sense; there's no CA in the loop for the call itself. Identity is inherited from the signaling layer (your app's login, HTTPS, the works) — which is also why compromised signaling can man-in-the-middle a call by swapping fingerprints, and why the truly paranoid verify out-of-band.
Q3Why does SRTP leave the RTP headers unencrypted — and what does that cost?
Click to reveal
Because the machinery around decryption needs them first: the jitter buffer reorders by sequence number and detects loss before any decrypting happens, stats and congestion control (module 8) read timestamps, and media servers (module 11) must route packets they hold no keys for. The headers are authenticated — unforgeable — just not hidden. The cost is metadata: an observer sees packet timing and sizes, which reveals who speaks when. Encryption hides the words; it cannot hide the rhythm.
Next up · Module 7
The media pipeline:
capture, codecs & RTP

The pipe is found, negotiated, and armored — and still empty. Module 7 fills it: how a microphone's waveform becomes Opus frames, why video needs keyframes and prediction, and how RTP's sequence numbers and timestamps let the receiver rebuild time itself from a hail of independent packets. Keep scrolling — it's written.

Module 7 · 01 · What we're actually shipping
The size of the problem

The pipe is found, negotiated, and armored. Now try to put media in it, raw, and watch the numbers explode. Uncompressed audio — 48,000 samples a second, 16 bits, stereo — is 1.5 Mbps. Manageable. Uncompressed video at just 720p30 — 1280×720 pixels, 30 times a second — is ~330 Mbps. Your home upload is maybe 20. The raw camera feed is more than a hundred times too big to send, before we've spent a single bit on anything else.

332 Mbps
raw 720p30 video, straight off the camera
~2 Mbps
the same video on the wire, encoded
~165×
compression, bought with prediction & loss
35 ms
module 1's capture+encode budget — spent right here

So the pipeline is forced into existence: capture → encode → packetize → (SRTP) → wire, mirrored on the far side by depacketize → jitter buffer → decode → play. The two interesting stops are the codec — where 165× is conjured, differently for audio and video — and RTP, the packaging that lets the receiver rebuild time itself from loose datagrams. This module walks both.

Hold onto this
Raw media is two orders of magnitude too big. Everything in this module is the machinery of shrinking it — and of surviving what shrinking costs.
Module 7 · 02 · The easy child
Audio: Opus, every 20 milliseconds

Audio is the well-behaved half. Every 20 ms, the browser grabs 960 samples per channel from the mic and hands them to Opus — module 5's payload 111, the codec that ended the codec wars by being best at everything: speech at 6 kbps, stereo music at 510, switching modes mid-stream. It works the way all modern compression works — predict, then encode only the surprise. Speech is highly predictable (a vocal tract is a physical system), so 3,840 raw bytes of stereo audio routinely become ~80 bytes: one small packet, fifty times smaller, every 20 ms, forever.

  • 1
    The frame is the atom. One 20 ms Opus frame per RTP packet, each decodable on its own — which is precisely what made module 2's "a loss costs exactly 20 ms" true, and what packet-loss concealment leans on to fake a missing frame from its neighbors.
  • 2
    Silence is nearly free. DTX (discontinuous transmission) collapses the not-talking half of every conversation to a whisper of comfort-noise updates. Your call's audio bitrate breathes with the conversation.
  • 3
    Each packet carries a spare of the last. Remember useinbandfec=1 from module 5's SDP? Every Opus packet can embed a low-fidelity copy of the previous frame — lose a packet and its successor partially resurrects it. Module 8 will call this FEC; Opus has it built into the codec itself.
Hold onto this
Audio = independent 20 ms frames, ~50× compressed, self-repairing via in-band spares. Losses stay small and local — exactly what module 2 promised.
Module 7 · 03 · The difficult child
Video: the prediction gamble

Video earns its 165× a riskier way. Consecutive frames are nearly identical — so the encoder sends a full picture only occasionally (a keyframe, or I-frame), and after it, only differences: "that block moved left, this region changed" (P-frames, often 10× smaller). The catch is written right into the scheme: every P-frame is defined relative to the frame before it. The picture you see is a chain of edits — and a chain has the failure mode chains have. Click any frame to lose it:

30 fps → each frame = 33 ms · keyframes are the tall yellow ones

Two consequences shape everything downstream. Keyframes are expensive — 10× the bits means a bitrate spike every time one is sent, so encoders space them out (or avoid them until asked). And the repair is a request: the receiver reports the damage and asks the encoder for a fresh keyframe — the PLI you just toggled — which costs a round trip. One more thing video gave up for latency: B-frames, which reference future frames, are brilliant compression and banned here outright — you can't reference a future you refuse to wait for.

Hold onto this
Video is a chain of edits to an occasional photograph. Break a link and everything downstream is fiction until a new photograph arrives — by request.
Module 7 · 04 · The envelope that carries time
RTP: rebuilding time from datagrams

Encoded frames now need packaging. UDP delivers anonymous datagrams — unordered, unlabeled, occasionally missing — and out of that hail the receiver must reconstruct media time: what plays when, what's late, what's gone. RTP (Real-time Transport Protocol) is a 12-byte header that carries exactly the facts needed and nothing else. Click every field:

V2
P·X·CC0·1·0
M0
payload type111
sequence number48,211
timestamp3,843,840
SSRC — stream id0x2F9A33C1
header extensiontransport-cc №7,204
encrypted payloadone 20 ms Opus frame · ~100 bytes of SRTP ciphertext
click a fieldtwelve bytes that let a receiver rebuild order, time, and identity from loose datagrams.

On the receiving side these fields feed the jitter buffer — module 1's 40 ms line item, finally explained: packets arrive with uneven spacing, the buffer holds just enough of them to reorder by sequence number and release by timestamp, converting network chaos back into a steady 33 ms heartbeat. Too small and every hiccup glitches; too big and you've spent the latency budget. Module 8 makes that buffer adaptive — and builds the whole feedback loop that keeps the pipeline honest.

Hold onto this
Sequence numbers give order, timestamps give time, SSRC gives identity. Twelve bytes turn anonymous datagrams back into a stream.
Module 7 · 05 · Before moving on
Check yourself

Out loud first, then click. Module 8 is the feedback loop that keeps all of this alive on a real network.

Q1Losing one audio packet costs 20 ms. Why can losing one video packet ruin half a second?
Click to reveal
Audio frames are independent — each Opus packet decodes alone, so damage never spreads. Video frames are a dependency chain: every P-frame is an edit to its predecessor, so one broken link corrupts every frame after it until the next keyframe rebases reality. At 30 fps with keyframes seconds apart, one lost packet can poison dozens of frames — which is why video repair is urgent, active (NACK, FEC, PLI — module 8), and why the receiver would rather freeze on the last good frame than show the corruption.
Q2RTP carries both a sequence number and a timestamp. Why isn't one enough?
Click to reveal
They measure different things. The sequence number counts packets — +1 each send, no exceptions — and exists for ordering and loss detection. The timestamp counts media time in sampling-clock ticks and exists for playback pacing. They routinely disagree: a 40 KB keyframe splits into ~34 packets with 34 consecutive sequence numbers but ONE shared timestamp (same instant of video); during DTX silence, sequence numbers advance packet by packet while timestamps leap ahead by the skipped audio. Order is not time — RTP refuses to conflate them.
Q3Why are B-frames — a huge compression win everywhere else — banned from the pipeline?
Click to reveal
A B-frame references a future frame, so it can't be decoded until that future arrives — the decoder must buffer and wait, by design. Netflix happily pays that wait (it buffers seconds anyway); module 1's budget cannot: waiting for the future is exactly the latency we've spent six modules refusing to add. Real-time video uses only the past — keyframes and forward prediction — and accepts the worse compression as another line on the bill from module 2.
Next up · Module 8
Staying alive:
jitter, loss & congestion

The pipeline works — on a good network, for now. Module 8 is the immune system: RTCP's feedback loop, the adaptive jitter buffer, NACK vs FEC vs PLI (when to re-ask, when to pre-pay, when to start over), and the congestion controller that discovers your bandwidth without a contract — module 2's last unpaid debt. Keep scrolling — it's written.

Module 8 · 01 · The feedback loop
RTCP: the call that watches itself

Everything so far works beautifully on the network you tested on. Real networks change by the second — Wi-Fi fades, someone starts a download, a cell tower hands you off. A live call can't file a bug report; it has to measure and adapt continuously. The measuring half is RTP's shadow-sibling: RTCP, a trickle of control packets sharing the same socket (module 5's a=rtcp-mux) and budgeted to a few percent of the media.

  • 1
    Receiver reports: "here's your last second, from where I sit" — fraction of packets lost, cumulative loss, interarrival jitter, highest sequence number seen. The sender learns what the network did to its stream.
  • 2
    Sender reports: a wall-clock ↔ RTP-timestamp mapping, which is how audio and video — separate streams, separate clocks (module 7) — get lip-synced at playout.
  • 3
    The urgent mail: NACK ("resend 48,210"), PLI ("chain broken, keyframe please" — module 7's repair request), and transport-cc feedback: batched per-packet arrival timestamps, echoing the header extension from module 7. That last one is the raw feed for section 04.
Hold onto this
RTP carries the media; RTCP carries the truth about how it went. Every adaptive behavior in this module is a consumer of that feedback.
Module 8 · 02 · The shock absorber
The jitter buffer learns to breathe

Module 1 charged 40 ms for the jitter buffer; module 7 showed its mechanics. The production version has one more trick: the 40 is not a constant. Packets arrive with uneven spacing — jitter — and the buffer's depth is a live bet on the next second's jitter. Buffer too little and every hiccup becomes a glitch; too much and you're mailing module 1's budget back. So the buffer tracks the recent arrival-spread and continuously re-sizes to cover roughly the 95th percentile — deepening the instant the network gets choppy, thinning quietly when calm returns.

The audio version (WebRTC's is a famous piece of engineering called NetEQ) hides the resizing itself: it time-stretches speech a few percent when it needs to grow the buffer and compresses when shrinking — no pitch shift, no dropped words, just conversation elastic enough that you never notice the network under it. Video needs no such subtlety: a frame can simply wait, or be late.

Hold onto this
The jitter buffer is a live wager on the next second of network. It re-bets every second — and audio stretches time itself to hide the re-betting.
Module 8 · 03 · Loss happened. Now what?
The repair toolkit

A packet is gone — module 2 taught us never to stall for it, but "don't stall" isn't a repair. WebRTC carries four tools, and choosing between them is a pure economics problem: re-ask (NACK — costs one RTT), pre-pay (FEC — costs bandwidth always, saves the RTT), start over (PLI — costs a whole keyframe), or fake it (concealment — costs a little fidelity). Same tools, different winner depending on the situation. Pick a scenario:

Hold onto this
NACK when the RTT fits inside your buffer. FEC when it doesn't. PLI when the chain is already dead. Conceal when nobody would notice anyway.
Module 8 · 04 · Module 2's last debt
Discovering bandwidth without a contract

The oldest IOU in this course comes due. Module 2: "UDP has no congestion control — you must be polite yourself." But how do you even know your share of a link nobody advertises? TCP's answer is to push until packets drop. For media that's a catastrophe — queues fill before they overflow, so by the time loss appears, seconds of latency already have. WebRTC's congestion controller watches a better smoke alarm: delay. Every packet carries a transport-cc number (module 7), the receiver echoes arrival times (section 01), and the sender watches the trend — one-way delay creeping up means a queue is building somewhere. Back off before the queue fills; probe gently upward when it drains. Try to break it:

true capacity (unknown to the sender) send-rate estimate
2.6 Mbps
current estimate → encoder target
3.0 Mbps
true capacity (cheating view)
0 ms
queue delay — the smoke alarm

Watch what the estimate feeds: the number isn't advisory — it becomes the encoder's bitrate target within milliseconds. Video re-targets, drops resolution or framerate down a ladder, climbs back when the estimate recovers. A pacer smooths module 7's keyframe spikes so one big frame doesn't read as a fake congestion event. And loss isn't ignored — sustained heavy loss still triggers a hard back-off; delay is just the early signal. This loop — measure, estimate, re-encode, repeat, forever — is the single biggest difference between a demo that works on office Wi-Fi and a call that survives a train ride.

Hold onto this
Rising delay is congestion's early warning; loss is its obituary. WebRTC reads the warning, backs off before the queue fills, and re-encodes to fit — forever.
Module 8 · 05 · Before moving on
Check yourself

Out loud first, then click. The media story is now complete — module 9 gives the same treatment to data.

Q1TCP infers congestion from packet loss. Why does WebRTC treat that signal as arriving too late?
Click to reveal
Because routers buffer before they drop. As a link saturates, its queue fills first — and every queued packet is added latency. By the time the queue overflows and produces the loss TCP waits for, hundreds of milliseconds of delay have already accumulated (bufferbloat), and module 1's budget died quietly along the way. Rising one-way delay IS the queue filling, observable in real time via transport-cc timestamps — so WebRTC backs off while the queue is still shallow, keeping latency flat instead of mourning it.
Q2When is NACK the wrong repair tool, and what do you reach for instead?
Click to reveal
NACK's price is one round trip: report the gap, wait for the retransmit. It's the cheapest tool whenever RTT fits comfortably inside the jitter buffer — a same-city video call, say. It's wrong three ways: when RTT is large (the retransmit misses the playout deadline), when loss is bursty (the retransmit likely dies in the same burst), and for tight-deadline audio (a 20 ms frame's moment passes before any round trip completes). In those cases you pre-pay instead: FEC ships redundancy alongside the media — Opus's in-band spare from module 7, or FlexFEC for video — trading constant bandwidth for zero repair latency.
Q3Trace the full loop: how does a queue building in some router in Kansas end up changing your encoder's bitrate?
Click to reveal
Every outgoing packet is stamped with a transport-wide sequence number (the header extension from module 7). The receiver logs each packet's arrival time and ships the batch back every ~50–100 ms in RTCP transport-cc feedback. The sender matches send-times to arrival-times, sees inter-packet delay trending upward — the Kansas queue growing — and the congestion controller cuts its bandwidth estimate. That estimate is wired straight into the encoder as its new bitrate target, so within a frame or two the video literally gets cheaper to ship. Queue drains, delay flattens, the estimate probes back up. The whole loop runs continuously, invisibly, for the entire life of the call.
Next up · Module 9
Data channels:
SCTP, the third transport

Media got RTP; signaling got the WebSocket. What about game state, file transfers, and the prompts your realtime model eats? Module 9 opens the data channel: SCTP living inside DTLS, and the two dials — reliability and ordering — that let every channel choose its own transport personality. Keep scrolling — it's written.

Module 9 · 01 · The cargo nobody planned for
The third cargo

Take inventory. Media rides SRTP — loss-tolerant, deadline-shaped. Signaling rides your WebSocket — reliable, server-relayed, rare. But apps keep producing a third kind of cargo: game state, file drops, chat, collaborative edits, the prompt stream feeding a realtime model. It wants the direct encrypted path we spent eight modules building — but its delivery needs are all over the map: a file chunk must arrive eventually and intact; a cursor position is garbage the moment a newer one exists. No single transport personality fits.

WebRTC's answer is a deep cut: SCTP, the third transport protocol — TCP and UDP's forgotten sibling, born in 2000 to carry telephone-network signaling. It always had the features this problem wants: it's message-oriented (you send messages, not a byte soup — no framing code ever again), multi-streamed (channels don't block each other), and — the prize — reliability à la carte, per message. One problem: no NAT or firewall on earth forwards native SCTP. So WebRTC smuggles it: SCTP inside DTLS inside UDP — module 6's asterisk, redeemed. Same punched path, same handshake, same 5-tuple.

Hold onto this
Data channels = a 25-year-old telephony transport, smuggled through the NAT in module 6's crypto envelope, so every message can pick its own guarantees.
Module 9 · 02 · Choose your own transport
The two dials

Every data channel is created with two decisions — ordered: true|false and a reliability cap (maxRetransmits or maxPacketLifeTime; unset = fully reliable). That's the whole API surface of the transport bargain. And here's the exam question this course has been building toward: the default setting quietly rebuilds TCP — module 2's monster, back by opt-in. Watch the same nine messages, with message 4 lost in transit, under three settings:

Arrives on the wire — identical every time
Your onmessage fires — the part the dials control

One subtlety in the reliability caps: maxRetransmits caps effort ("try twice, then move on") while maxPacketLifeTime caps time ("useless after 150 ms, stop sending it") — the second is exactly the shape of live state, where the deadline, not the attempt count, is what defines worthless. And because SCTP is multi-streamed, these choices are per channel: your reliable file channel stalling on a loss does not block your unreliable game-state channel riding the same connection.

Hold onto this
Two dials per channel: ordered? how hard to retry? Default = TCP rebuilt (stalls included). The craft is knowing when to turn both off.
Module 9 · 03 · The passenger manifest
What rides it

Same four channel personalities, four very different passengers — note how each one's dials fall straight out of asking "is this data deadline-shaped or completeness-shaped?":

Game stateordered:false · maxRetransmits:0
Player positions 20× a second. A lost update is already superseded by the next one — retransmitting it would deliver the past. Pure UDP semantics, but with message framing and module 6's encryption for free.
File dropordered:false · reliable
Every chunk must land; arrival order is irrelevant (chunks carry offsets — reassemble on disk). Unordered dodges the stall while keeping the guarantee. Backpressure via bufferedAmount: pause sending when the queue swells, resume on bufferedamountlow. Keep chunks ~16 KB for interop.
Chat & controldefaults · ordered · reliable
Messages are rare, small, and must arrive in the order typed — the one workload where rebuilding TCP is exactly right. A 200 ms stall on a chat message is invisible; a missing one is a bug report.
Realtime model I/Omixed — per stream
The fal-shaped case: prompts and parameters ride up a reliable channel (losing a prompt is a broken product) while continuous knobs — brush strokes, sliders, camera pose — ride an unreliable sibling where only the latest matters. Frames come back down as SRTP. Three cargoes, one connection, one handshake.
Hold onto this
Ask one question per stream: deadline-shaped or completeness-shaped? The dials answer themselves — and different answers can share one connection.
Module 9 · 04 · Before moving on
Check yourself

Out loud first, then click. Every part now exists — module 10 bolts them into one machine.

Q1You ship game position updates over a default data channel. What monster did you just re-summon, and what two options banish it?
Click to reveal
Head-of-line blocking — module 2's monster, rebuilt by opt-in. Defaults mean ordered + fully reliable: one lost position update stalls every newer update behind it for a retransmission round trip, then delivers a burst of stale positions — the exact TCP pathology we left. Banish it with ordered: false, maxRetransmits: 0: UDP semantics, but keeping SCTP's message framing and DTLS's encryption.
Q2SCTP is a real IP transport — why does WebRTC run it inside DTLS instead of directly on the network, as designed?
Click to reveal
Module 3's boxes only speak TCP and UDP: a packet with SCTP's protocol number hits a NAT that has no idea how to build a mapping for it and dies at the first home router. Tunneling SCTP inside DTLS-over-UDP means it rides the path ICE already punched, inside the encryption module 6 already negotiated — one handshake, one 5-tuple, zero new traversal problems. The 25-year-old transport gets to exist by dressing as UDP.
Q3maxRetransmits vs maxPacketLifeTime — same dial? When do you reach for each?
Click to reveal
Both are partial reliability — ways of telling SCTP when to stop caring — but they cap different resources. maxRetransmits caps effort: "try N times, then abandon" — fits best-effort bulk data where you'll tolerate a couple of attempts. maxPacketLifeTime caps time: "this is worthless after 150 ms" — fits deadline-shaped live state, where obsolescence, not attempt count, defines failure. Live cursors and poses want lifetime; you can only set one of the two per channel.
Next up · Module 10
RTCPeerConnection:
assembling the machine

Nine modules of parts on the workbench: transports, tournaments, handshakes, pipelines, feedback loops, data channels. Module 10 bolts them together — the actual API, the three state machines firing in parallel, and one annotated walkthrough from new RTCPeerConnection() to first frame. Keep scrolling — it's written.

Module 10 · 01 · The payoff
The whole course in twenty lines

Here is the entire caller side of a video call — every line of application code required. Nine modules of machinery hide under these twenty lines, and now you can see all of it. Click any line and it will tell you which module it summons:

click a lineevery line is a lever on machinery you now understand.

Notice what's missing: no DTLS, no SRTP, no jitter buffer, no congestion controller, no keepalives, no XAUTOCLAIM-style recovery code. The API's design rule is precise: everything with exactly one correct behavior is automatic; everything requiring an application decision is exposed. You must supply the signaling transport (module 5 refused to pick one) and declare your cargo — the machine does the rest, forever, unsupervised.

Hold onto this
Twenty lines of levers on nine modules of machinery. What the API hides is everything with only one right answer.
Module 10 · 02 · What happens when you run it
Three state machines, firing in parallel

Call setup isn't a sequence — it's three overlapping progressions, each with its own state property and its own change event: signalingState (module 5's dance), iceGatheringState (module 4's candidate hunt), and connectionState (the rollup of ICE checks + DTLS). Most WebRTC confusion is reading one machine's state and expecting another machine's behavior. Step through a real setup:

signalingState — the offer/answer dance
iceGatheringState — the candidate hunt
connectionState — checks + DTLS, rolled up
Hold onto this
Three clocks tick at once: the dance, the hunt, the connection. ontrack fires off the SDP, not off media — the classic trap.
Module 10 · 03 · The clinic
When it breaks anyway

Two instruments cover almost everything: chrome://webrtc-internals (live dashboards of every peer connection — the candidate grid, the selected pair, bitrate graphs) and pc.getStats(), the same data as an API. The craft is knowing which number to look at for which symptom — and after nine modules, every diagnosis below should read as a callback. Pick your ailment:

Hold onto this
webrtc-internals for eyes, getStats() for code. Every symptom traces to a module — the debugging skill is knowing which one.
Module 10 · 04 · Before moving on
Check yourself

Out loud first, then click. The machine is assembled — the last two modules take it to scale.

Q1Your twenty lines never mention DTLS, SRTP, jitter buffers, or congestion control. Where did four modules of machinery go?
Click to reveal
Below the API, on purpose. The certificate is minted and its fingerprint written into the SDP by createOffer; the DTLS handshake fires automatically the instant ICE nominates a pair; SRTP keys are exported without a single exposed byte; NetEQ and the congestion controller run for the life of the call with no API at all (you can only observe them via getStats). The dividing line is decision-shaped: signaling transport, which tracks, which data-channel dials — those are yours. Everything with one correct behavior belongs to the machine.
Q2ontrack fired, srcObject is attached — and the video element is still black. Three suspects, in order?
Click to reveal
First: autoplay policy — browsers refuse to auto-play unmuted video without a user gesture; set muted and call .play(), or gate the call behind a click. Second: you're early — ontrack fires from the SDP at setRemoteDescription (module 10's trap), before any media exists; frames only paint after ICE + DTLS complete AND the first keyframe arrives (module 7 — check framesReceived in getStats; if it's climbing, you're just waiting on the decode). Third: the sender's track is actually muted or the camera never started — check the track's muted/readyState on the far side, not yours.
Q3Mid-call, connectionState goes "disconnected"… then recovers. Later it goes "failed". Why are these different states, and what's the right code for each?
Click to reveal
"Disconnected" means checks on the nominated pair are failing right now — usually a Wi-Fi blip or a network handoff — and the machine is still trying; consensus handling is a debounce: show "reconnecting…", do nothing rash, it often self-heals in seconds. "Failed" means ICE exhausted every pair and gave up: nothing will heal on its own. The right code is the last line of the walkthrough — pc.restartIce() — which mints fresh ufrag/credentials, regathers, re-races module 4's tournament (possibly on the new network), and swaps the path without tearing down the session.
Next up · Module 11
Beyond two peers:
mesh, SFU, MCU

Everything so far assumed two peers. Add a third and the math starts to bite; add ten and it eats you alive. Module 11 is the topology story — why group calls reintroduce servers, what an SFU actually forwards, simulcast, and why "a server that speaks WebRTC is just a well-connected peer" is the sentence that explains half the realtime industry. Keep scrolling — it's written.

Module 11 · 01 · The math turns hostile
The N² problem

Eleven modules of machinery, all built on one quiet assumption: two peers. Now invite three friends. In a pure peer-to-peer group call, every participant connects to every other — and every participant must upload a separate copy of their video to each peer, because there is no one else to make copies. Run the numbers on a five-person call:

10
connections — ten full ICE + DTLS machines (5×4÷2)
6 Mbps
YOUR upload: 4 copies × 1.5 Mbps — most home uplinks are done
×4
encoder instances per client — laptop fans at takeoff thrust
×4
congestion controllers per client, fighting each other for the same uplink

The binding constraint is the client uplink — the scarcest bandwidth on the internet — and it scales with N when it needed to scale with 1. Mesh is genuinely great at N=2 (it's everything this course built) and acceptable at 3–4. At 5 it wheezes; at 8 it's fiction. Fixing it means someone must make the copies — and that someone is a server. Module 1's wall, willingly breached; the only question left is what the server does with the media.

Hold onto this
Group calls fail on the client uplink: N−1 copies of you. The fix is a copy machine in a datacenter — the debate is only over how much it should think.
Module 11 · 02 · Pick your copy machine
Three topologies

Three answers to "who makes the copies", from no-server to all-server. Drag the room size and watch each architecture's bill:

Participants · 6
6
connections (ICE+DTLS machines)
1.5 Mbps
your upload
7.5 Mbps
your download
45 Mbps
server egress (the bill)

Hold onto this
Mesh spends the client uplink. SFU spends datacenter bandwidth. MCU spends datacenter CPU. Bandwidth in a datacenter is the cheapest of the three — which is why the SFU won.
Module 11 · 03 · The SFU's craft
Simulcast: one sender, every receiver

The SFU forwards without decoding — which creates its defining puzzle. Your 720p stream is perfect for the desktop on fiber and lethal for the phone on hotel Wi-Fi, and the SFU can't transcode a middle ground (that would make it an MCU). The fix is simulcast: the sender encodes the same video at two or three qualities simultaneously and uploads all of them; the SFU forwards whichever layer fits each receiver:

The senderuploads all layers · ~2.2 Mbps
One camera, three encodes: 720p @ 1.5 Mbps · 360p @ 0.5 · 180p @ 0.15. Roughly 45% more upload than the top layer alone — the price of serving everyone from one connection. Module 5's SDP negotiates the layers; module 8's controller can drop the top layer first under pressure.
Desktop · fibergets 720p
Its leg's bandwidth estimate (module 8, run per subscriber by the SFU) says there's headroom — forward the full layer, untouched, still the sender's own encoded packets.
Phone · LTEgets 360p
Mid layer. When its network dips, the SFU switches it to 180p instantly, alone — no renegotiation, no effect on the sender or the desktop. Per-leg adaptation is the whole point.
Laptop · hotel Wi-Figets 180p
Bottom layer, plus the SFU answers this leg's NACKs and PLIs itself (module 8's repair toolkit, terminated per leg) so one sad hotel connection never triggers keyframes for the whole room.

The refinement is SVC (scalable video coding): instead of three separate encodes, one layered bitstream where the SFU peels off enhancement layers per leg — same idea, less upload. Add per-leg feedback termination, dominant-speaker detection (forward the loudest at high quality, thumbnails for the rest), and pagination, and you have the actual engineering inside Meet, Zoom, and every LiveKit-shaped platform.

Hold onto this
The sender encodes a menu; the SFU serves each leg what its network can chew. Adaptation without transcoding — that's the whole trick.
Module 11 · 04 · The unifying sentence
A server is just a well-connected peer

Step back and notice what the SFU really proved: nothing in modules 1–10 requires the other peer to be a browser. Anything that speaks ICE, DTLS, and RTP is a peer — and a peer with a public IP and datacenter bandwidth is simply a very lucky one. NAT traversal nearly evaporates (the server offers host candidates; the client's outbound always works; TURN remains only for UDP-hostile client networks). Signaling collapses to one HTTPS round trip. Everything else — the tournament, the fingerprints, the pipeline, the feedback loops — runs verbatim. That one sentence explains half the realtime industry:

  • 1
    SFUs — the group-call copy machine you just met: N legs, each an ordinary peer connection.
  • 2
    Realtime AI inference — the fal-shaped case: browser ↔ gateway (signaling via one POST) ↔ GPU runner speaking WebRTC. Prompts ride a reliable data channel up, continuous controls ride an unreliable one, generated frames ride SRTP down — sub-100 ms mouth-to-model-to-eye, which module 1 proved a WebSocket can never deliver.
  • 3
    Broadcast ingest & playout — WHIP (publish into a server) and WHEP (play out of one): OBS-to-platform and sub-second live streams, each just a peer connection with an HTTP-shaped handshake.
  • 4
    Cloud gaming & remote desktops — a datacenter GPU as the sending peer, your inputs on a data channel: the same diagram as the fal case with the model swapped for a game engine.

And be honest about what's traded: module 1's "no server in the media path" is deliberately surrendered — because the server is the counterparty, or because N made pure P2P impossible. What's kept is the half that always mattered more: module 2's transport bargain. UDP semantics, loss tolerance, adaptive everything. The walls were never the point; the milliseconds were.

Hold onto this
WebRTC isn't a browser-to-browser protocol — it's a low-latency media protocol whose peers can be anything. The industry is that sentence, deployed.
Module 11 · 05 · Before moving on
Check yourself

Out loud first, then click. One module left: building and debugging the real thing.

Q1Why exactly does mesh die around five participants — which resource gives out first?
Click to reveal
The client uplink. Each participant must upload N−1 separate copies (there's nobody to copy for them): at five people that's 6 Mbps of video against home uplinks that often top out near there — before audio, FEC, or headroom. Close behind: N−1 simultaneous encodes cooking the CPU, and N−1 independent congestion controllers (module 8) each probing the same uplink and misreading each other's traffic as congestion. Downloads are rarely the binder — uplinks are the internet's scarcest resource.
Q2An SFU never decodes video. How can it give the fiber desktop 720p and the hotel-Wi-Fi laptop 180p from the same sender?
Click to reveal
Simulcast: the sender encodes the same camera at 2–3 qualities and uploads all of them (~45% extra upload); the SFU runs module 8's bandwidth estimation per subscriber leg and forwards whichever layer fits, switching layers per leg without touching the sender. SVC is the refinement — one layered bitstream with peelable enhancement layers. Selection, not transcoding: the packets each receiver gets are still the sender's own encodes.
Q3In an SFU room, module 6 promised end-to-end encryption. Who can actually read your video now — and what restores true e2e?
Click to reveal
The SFU can. Each leg is its own peer connection — its own DTLS handshake, its own SRTP keys — so the server decrypts your packets to route them and re-encrypts per subscriber. Module 6's fingerprint guarantee now authenticates client↔SFU, not client↔client. Restoring true e2e means an extra layer the SFU never gets keys for: insertable streams / SFrame encrypt each media frame before SRTP, so the SFU still routes by the readable RTP headers (module 6's design choice paying off) while the payload stays opaque to it. The trade: the SFU can no longer inspect or reprocess media at all.
Next up · Module 12 · The finale
Capstone:
build & debug a real call

Twelve modules of theory earn their keep: a complete working two-browser call — every file, every line — plus the field manual: what to log, what to graph, and the ten-minute triage ritual for when a call won't connect. The machine, in your hands. Keep scrolling — the finale is written.

Module 12 · 01 · No more diagrams
The call inside this page

Twelve modules of theory; zero packets. Time to fix that. The button below builds two real RTCPeerConnections in this very page and calls one from the other — real ICE checks, a real DTLS handshake, real SRTP video, a real data channel measuring real round trips. The "camera" is a canvas (so no permission prompt, and it runs even on the shelf), and both peers share your machine — module 4's same-network scenario, where host↔host wins in single-digit milliseconds. Everything you learned, executing:

two peers, one page, zero servers
peer 1 · sender — a canvas pretending to be a camera
peer 2 · receiver — SRTP, decoded back to pixels
idle
connectionState · both peers
nominated pair (module 4)
data-channel round trip (module 9)
SRTP bytes received (module 6)
frames decoded / sec (module 7)
click → connected (module 10's clocks)
Hold onto this
That's not a simulation. Real ICE, real DTLS, real SRTP — the whole machine, assembled from parts you can now name.
Module 12 · 02 · The real thing
Every file you need

The loopback above cheats exactly once: its "signaling" is two variables in the same page. The real app replaces that with module 5's mutual friend — and that's the only difference. Here is a complete two-browser video call. File one, the entire signaling server — a dumb forwarder with rooms:

server.js · node + ws · the postman from module 5
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8443 });
const rooms = new Map();

wss.on("connection", ws => {
  ws.on("message", data => {
    const msg = JSON.parse(data);
    if (msg.join) {
      ws.room = msg.join;
      const peers = rooms.get(ws.room) ?? [];
      ws.polite = peers.length > 0;          // second to arrive = polite (module 5's glare fix)
      peers.push(ws); rooms.set(ws.room, peers);
      ws.send(JSON.stringify({ polite: ws.polite }));
      return;
    }
    for (const peer of rooms.get(ws.room) ?? [])
      if (peer !== ws && peer.readyState === 1) peer.send(data);
  });
});
call.js · the client — module 10's twenty lines + perfect negotiation
const ws = new WebSocket("wss://your.host:8443");
const pc = new RTCPeerConnection({ iceServers: [
  { urls: "stun:stun.l.google.com:19302" },
  { urls: "turns:turn.your.host:443", username: U, credential: C }]});
let polite = false, makingOffer = false;

ws.onopen = () => ws.send(JSON.stringify({ join: location.hash || "room1" }));
const send = obj => ws.send(JSON.stringify(obj));

const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
document.querySelector("#me").srcObject = stream;
stream.getTracks().forEach(t => pc.addTrack(t, stream));

pc.ontrack = e => document.querySelector("#them").srcObject = e.streams[0];
pc.onicecandidate = e => e.candidate && send({ candidate: e.candidate });
pc.onnegotiationneeded = async () => {           // fires when tracks/channels change
  makingOffer = true;
  await pc.setLocalDescription();                 // no-arg: makes the right offer
  send({ description: pc.localDescription });
  makingOffer = false;
};
pc.onconnectionstatechange = () =>
  pc.connectionState === "failed" && pc.restartIce();

ws.onmessage = async ({ data }) => {
  const msg = JSON.parse(data);
  if (msg.polite !== undefined) { polite = msg.polite; return; }
  if (msg.description) {
    const clash = msg.description.type === "offer" &&
                  (makingOffer || pc.signalingState !== "stable");
    if (clash && !polite) return;                 // impolite: ignore the collision
    await pc.setRemoteDescription(msg.description);  // polite: rollback is automatic
    if (msg.description.type === "offer") {
      await pc.setLocalDescription();             // no-arg: makes the answer
      send({ description: pc.localDescription });
    }
  }
  if (msg.candidate) await pc.addIceCandidate(msg.candidate);
};

run it: node server.js · serve the page over https (getUserMedia demands it; localhost is exempt) · open the same #room in two browsers. that's the entire product.

Hold onto this
~60 lines total, and half of them are module 5's politeness protocol. Everything hard lives below the API — which is the point of the API.
Module 12 · 03 · The field manual
The ten-minute triage

A call won't connect and someone is waiting. Run this ritual in order — each step eliminates a whole module's worth of suspects, and the order matters because early steps are cheap and late steps are rare:

  • 1
    Did signaling complete? Log offer sent / offer received / answer sent / answer received on both ends. Half of all "WebRTC bugs" die here, in your own WebSocket code. (module 5)
  • 2
    Read the three clocks. signalingState stuck at have-local-offer = the answer never arrived or never applied. connectionState stuck at "new" with stable signaling = candidates aren't flowing. (module 10)
  • 3
    Open webrtc-internals → candidate grid. Zero remote candidates = signaling bug (step 1 lied to you). Candidates present but no relay rows = your TURN config is broken or absent. (modules 4–5)
  • 4
    The definitive TURN test: set iceTransportPolicy: "relay" and retry. Connects → your TURN works and the direct paths are the problem (hostile NATs — expected sometimes). Fails → fix TURN first; nothing else matters until the floor exists. (module 4)
  • 5
    Connected but silent/black? inbound-rtp bytesReceived climbing → it's the last inch: autoplay policy, srcObject wiring, or a pending keyframe. Not climbing → the sender never sent; debug the other end. (modules 7, 10)
  • 6
    Quality bad? Read qualityLimitationReason and believe it: "bandwidth" = module 8 defending latency (working as intended); "cpu" = the encoder, not the network. (module 8)
  • 7
    Dies mid-call? Watch connectionState: disconnected that self-heals = a blip; failed = the network changed under you — confirm restartIce() is wired to fire. (modules 4, 10)

And for production, log the fleet, not the call: time-to-connected percentiles, connectionState transition counts, selected-pair type distribution (a rising relay share means your users' networks are getting more hostile — TURN capacity is now load-bearing), and TURN egress per region. Those four graphs answer every "is realtime degraded?" question you'll ever be asked.

Hold onto this
Triage in order: signaling → states → candidates → forced relay → last inch → quality → resilience. Each step retires one module's suspects.
Module 12 · 04 · The final exam
Sign-off

Three questions spanning all twelve modules. If these flow, you're done — genuinely.

Q1The grand trace: from clicking "call" to the first frame of video — name everything that happens, in order.
Click to reveal
getUserMedia captures; RTCPeerConnection builds; tracks and data channels are declared. createOffer writes the SDP — codec menu, ice-ufrag/pwd, DTLS fingerprint — and setLocalDescription commits it AND starts candidate gathering (host, srflx via the STUN mirror, relay from TURN). The offer rides signaling; the answer returns; candidates trickle both ways the whole time. ICE crosses the lists into pairs and races STUN checks — the checks themselves punching the NATs — until a pair is nominated. DTLS handshakes on the winner, each cert verified against the fingerprint signaling carried. SRTP keys are exported. The encoder crushes ~330 Mbps to ~2, RTP stamps sequence and time, packets fly, the jitter buffer rebuilds the heartbeat, the first keyframe decodes — pixels. And from that moment: congestion control, keepalives, and RTCP feedback, forever, untouched by your code.
Q2Your PM asks: "we already have WebSockets — why is this fifty times more complicated?" The honest two-minute answer?
Click to reveal
WebSockets are perfect for messages and structurally wrong for live media, twice over. They ride TCP, which converts every packet loss into a stall — late audio instead of lost audio, and late is worse than lost. And they terminate at our server, so every media byte pays the relay tax in latency, egress, and privacy. WebRTC is UDP semantics (glitch, don't stall) on a direct path — and the fifty-times-more-complicated part is the true cost of that: finding peers behind NATs, encrypting without a middleman, rebuilding congestion control, making media survive loss. We don't pay it because it's fun; we pay it because 150 milliseconds is a law of conversation, and TCP-through-a-server can't meet it.
Q3Design question: realtime model inference over WebRTC (the fal shape). Which pieces of this course do you deploy, and which do you deliberately skip?
Click to reveal
Deploy: the runner as a WebRTC peer with a public address (module 11's well-connected peer); signaling collapsed to one HTTPS offer/answer round trip through the gateway (WHIP-shaped, module 5); TURN with turns:443 and ephemeral credentials for the ~10–15% of clients on hostile networks (modules 3–4); frames down as SRTP, prompts up a reliable data channel, continuous controls up an unreliable sibling (modules 6–9); fleet metrics on pair types and time-to-connected (module 12). Skip: STUN infrastructure of your own (public mirrors suffice), an SFU (it's 1:1 — until you want fan-out or a stable media edge, module 11's other door), and mesh anything. The art is knowing the machine well enough to leave most of it in the box.
Peer Pressure · 12 / 12 · Complete
The machine,
yours

Twelve modules ago, the browser couldn't speak first. Now you can trace a packet from a canvas in this very page, through a punched NAT, past a fingerprint check, into a jitter buffer, and out as pixels — and name every hand that touched it. The course is complete. The best next step isn't more reading: build the sixty-line app, break it on a train, and triage it back to life.

started · module 1 · the real-time problem finished · module 12 · a live call in the doc