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.
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.
- 1Client 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.
- 2The 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.
- 3Events 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?
"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.
One-way network latency: 60 ms
Fixed costs eat ~90 ms before the network gets a single millisecond. The wire's allowance is tiny.
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?
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.
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.
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.
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.
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. →
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:
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.
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.
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.
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.
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 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:
- 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.
- 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.
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:
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.
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.
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:
- 1Ordering & 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.)
- 2Politeness → 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.)
- 3Reliability à 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.)
- 4And 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.
Out loud first, then click. Module 3 assumes these are solid.
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 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.
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."
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:
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:
(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.)
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.
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.
Out loud first, then click. Module 4 stands entirely on these three.
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.
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.
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.
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:
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.
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.
Out loud first, then click. Module 5 finally meets the "mutual friend" — make sure you know why it's needed.
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.
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.
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:
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.
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:
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.
Out loud first, then click. Module 6 leans hard on one line of the SDP — make sure you know which.
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.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.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.
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:
- 1TLS 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.
- 2TLS 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.
- 3TLS 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.
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:
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.
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.
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:
- 1Every 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.
- 2Headers 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.
- 3Per-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.
Out loud first, then click. The pipe is now found, negotiated, and armored — module 7 finally puts media in it.
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.
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.
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.
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.
- 1The 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.
- 2Silence 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.
- 3Each packet carries a spare of the last. Remember
useinbandfec=1from 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.
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:
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.
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:
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.
Out loud first, then click. Module 8 is the feedback loop that keeps all of this alive on a real network.
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.
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.
- 1Receiver 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.
- 2Sender reports: a wall-clock ↔ RTP-timestamp mapping, which is how audio and video — separate streams, separate clocks (module 7) — get lip-synced at playout.
- 3The 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.
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.
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:
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:
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.
Out loud first, then click. The media story is now complete — module 9 gives the same treatment to data.
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.
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.
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:
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.
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?":
bufferedAmount: pause sending when the queue swells, resume on bufferedamountlow. Keep chunks ~16 KB for interop.Out loud first, then click. Every part now exists — module 10 bolts them into one machine.
ordered: false, maxRetransmits: 0: UDP semantics, but keeping SCTP's message framing and DTLS's encryption.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.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.
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:
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.
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:
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:
Out loud first, then click. The machine is assembled — the last two modules take it to scale.
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.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.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.
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:
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.
Three answers to "who makes the copies", from no-server to all-server. Drag the room size and watch each architecture's bill:
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 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.
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:
- 1SFUs — the group-call copy machine you just met: N legs, each an ordinary peer connection.
- 2Realtime 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.
- 3Broadcast 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.
- 4Cloud 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.
Out loud first, then click. One module left: building and debugging the real thing.
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.
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:
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:
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);
});
});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.
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:
- 1Did 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)
- 2Read 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)
- 3Open 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)
- 4The 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) - 5Connected 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)
- 6Quality bad? Read
qualityLimitationReasonand believe it: "bandwidth" = module 8 defending latency (working as intended); "cpu" = the encoder, not the network. (module 8) - 7Dies 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.
Three questions spanning all twelve modules. If these flow, you're done — genuinely.
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.