WebRTC Architecture

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

WebRTC (Web Real-Time Communication) is a set of browser APIs and network protocols that let two endpoints exchange audio, video and arbitrary data with low latency, directly where the network allows it. It is not one protocol: it is a stack of about a dozen IETF protocols assembled by a W3C API. This page is the map of that stack. It names every moving part, shows how the parts relate during a call, lists the specifications with their current numbers, tells you where to look when a call does not connect, and maps the three scenarios of this section to the features each one needs. The following pages take the parts one at a time.

Versions and specifications were checked on 2026-10-24. The baseline is the W3C WebRTC 1.0 Recommendation (the 2025-03-13 edition, which incorporates the amendments tested for interoperability) with Media Capture and Streams, the IETF JavaScript Session Establishment Protocol in RFC 9429 (which obsoletes RFC 8829), BUNDLE in RFC 9143 (which obsoletes RFC 8843), SDP in RFC 8866 (which obsoletes RFC 4566), ICE in RFC 8445, STUN in RFC 8489 and TURN in RFC 8656. The examples were run on Node.js 24 with ws 8.22.0 and puppeteer-core 25.16.0 driving Chrome 155 with fake camera and microphone devices.

What WebRTC is for

A browser can already talk to a server over HTTP and WebSocket. WebRTC adds three things that those cannot give:

  • Direct, encrypted, congestion-controlled media. Audio and video are captured, encoded, packetised, encrypted and sent over UDP, with jitter buffers, loss concealment and bandwidth estimation built into the browser. A WebSocket over TCP cannot drop a late video frame, so it cannot hold the 150 ms mouth-to-ear budget on a lossy network.

  • Unreliable, unordered data. Data channels offer a message transport over UDP whose reliability is chosen per channel, which is what a game needs and what WebSocket cannot offer.

  • NAT traversal. ICE finds a working path between two machines that sit behind home routers and corporate firewalls, falling back to a relay when no direct path exists.

What WebRTC does not give you is a way to find the other peer. There is no directory, no room, no identity and no call-setup channel in the standards. Applications build that themselves, and the part they build is called signalling.

The moving parts

A call involves seven cooperating parts. The figure shows them as a stack, with signalling drawn beside it because it is outside the standard.

The WebRTC protocol stack: JavaScript APIs, codecs over RTP and SRTP, data channels over SCTP, DTLS, ICE with STUN and TURN, UDP and IP, and application-defined signalling beside it

Capture

navigator.mediaDevices.getUserMedia() and getDisplayMedia() turn a camera, a microphone or a screen into MediaStreamTrack objects. They need a secure context (HTTPS or localhost), a user permission, and for getDisplayMedia() a user gesture. Device labels stay empty until permission is granted, an iframe needs a Permissions-Policy that delegates camera and microphone, and remote audio is subject to the browser’s autoplay rules. Capture is specified separately from the peer connection (W3C Media Capture and Streams) and is covered in Media Capture and Devices and Screen Sharing.

The peer connection

RTCPeerConnection is the object that owns one connection to one remote peer. It holds the ICE agent, the DTLS and SRTP state, the RTP senders and receivers (one RTCRtpTransceiver per media section), and the SCTP association used by data channels. You attach tracks with addTrack() or addTransceiver(), create data channels with createDataChannel(), and the browser tells you what to do next through events: negotiationneeded, icecandidate, track, datachannel, connectionstatechange and iceconnectionstatechange. The object is described in detail in Peer Connections and SDP.

A peer connection is one transport to one peer. A call between ten people is ten peer connections per participant in a mesh, or one in an SFU design (see Topologies: Mesh, SFU and MCU).

Signalling

Before two browsers can exchange media they must agree on what to exchange (codecs, number of streams, data channels) and learn each other’s network addresses and certificate fingerprints. The agreement is written as a session description in SDP and exchanged as an offer and an answer (JSEP, RFC 9429). The addresses arrive as ICE candidates. WebRTC defines how to produce and consume these blobs but deliberately does not define how to deliver them: that is the application’s signalling channel. Chatterbox-style WebSocket servers, Socket.IO, SignalR, XMPP with Jingle and SIP over WebSocket (RFC 7118) are all valid. Signalling carries only a few kilobytes per call setup, so any of the servers in this section is fast enough, but it must be reliable, ordered per peer and authenticated. Signaling and Perfect Negotiation builds a complete signalling server and handles the glare case, where both peers offer at once.

ICE, STUN and TURN

Interactive Connectivity Establishment (RFC 8445) is the algorithm that finds a working network path. Each side gathers candidates: its own addresses (host), the address a STUN server (RFC 8489) reports for it (server reflexive), the address of a TURN relay (RFC 8656) allocated for it (relay), and addresses learned during checks (peer reflexive). The peers pair their candidates, send STUN binding requests on each pair, and nominate the best pair that answers. Candidates travel through signalling, either all at once or trickled as they are found (RFC 8838). Most connections succeed with host or server-reflexive pairs. A minority, typically those behind symmetric NATs or UDP-blocking firewalls (see the NAT section), need a TURN relay, which is why a production deployment always runs one. See ICE, STUN and TURN.

DTLS-SRTP

Every WebRTC connection is encrypted; there is no unencrypted mode. After ICE nominates a pair, the peers run a DTLS handshake (RFC 6347; the WebRTC security architecture in RFC 8827 requires support for DTLS 1.2) over the same UDP flow. The certificate each side uses is self-generated, so DTLS authenticates nothing by itself: instead each side puts the fingerprint of its certificate in its SDP (a=fingerprint), and the handshake succeeds only if the certificate presented matches the fingerprint received through signalling. The security of a call therefore depends on the integrity of the signalling channel. From the handshake the peers derive keys for SRTP (RFC 3711, with the key derivation in RFC 5764), which protects the audio and video packets, and the same DTLS session carries SCTP for data channels.

DTLS-SRTP encrypts each hop. When the two ends are browsers connected directly, hops equal ends. When an SFU sits in the middle, the SFU terminates DTLS and SRTP from each sender and re-encrypts to each receiver, so it can see the media. End-to-end confidentiality through an SFU needs a second layer, SFrame (RFC 9605), applied with encoded transforms (RTCRtpScriptTransform) and a key-distribution scheme. That is the subject of End-to-End Encryption.

Media transport: RTP and RTCP

Encoded audio and video frames are split into packets and sent with RTP (RFC 3550); the receiver reports loss and jitter with RTCP. RFC 8834 lists what a WebRTC endpoint must support: RTP and RTCP multiplexed on one port (rtcp-mux), the audio-level header extension (RFC 6464) that SFUs use for active-speaker detection, retransmission requests (NACK), keyframe requests (PLI and FIR), and congestion control. Congestion control matters most: browsers estimate the available bandwidth continuously and adapt the encoder bitrate, using transport-wide congestion control feedback (a widely implemented IETF draft, draft-holmer-rmcat-transport-wide-cc-extensions) with Google Congestion Control. RFC 8888 standardises congestion-control feedback in RTCP and is the number to cite, but browsers still ship the draft mechanism. The statistics are covered in Stats, Quality and Bandwidth.

BUNDLE (RFC 9143) multiplexes all media sections and the data channel over one ICE transport, so one UDP flow, one DTLS handshake and one NAT mapping serve the whole call. This is why the SDP shows a=group:BUNDLE 0 1 2.

Data channels: SCTP over DTLS

RTCDataChannel messages ride on SCTP (RFC 9260, which obsoletes RFC 4960, with the DTLS encapsulation in RFC 8261) inside the DTLS session. RFC 8831 defines the data channel protocol, RFC 8832 the in-band Data Channel Establishment Protocol that opens a channel, and RFC 8841 the SDP line (m=application ... UDP/DTLS/SCTP webrtc-datachannel) that negotiates the association. SCTP is message-oriented, supports many independent streams so one lost packet does not block other channels, and offers per-channel reliability: ordered or unordered, with a retransmission limit or a lifetime. See Data Channels, which builds on the AI-oriented data channel page.

Codecs

A WebRTC endpoint must implement Opus and G.711 for audio (RFC 7874) and VP8 and H.264 constrained baseline for video (RFC 7742). Browsers add VP9 and AV1, and some add H.265, depending on the version and the hardware. The codec list is negotiated in SDP (a=rtpmap) and the sender may refuse any codec the receiver offers. Opus is the only audio codec most applications use. For video, the choice of codec trades CPU, bandwidth and compatibility, and is covered in Codecs, Simulcast and SVC.

A call from start to finish

The sequence below is a call between two Huddle participants through a signalling server and a STUN or TURN service. Signalling uses the WebSocket path; media and the data channel use the direct or relayed UDP path.

sequenceDiagram participant A as Alice (browser) participant S as Signalling server participant B as Bob (browser) participant T as STUN / TURN A->>A: getUserMedia, new RTCPeerConnection, addTrack A->>T: STUN binding request, TURN allocate T-->>A: server reflexive and relay candidates A->>S: offer (SDP with fingerprint) S->>B: offer B->>B: getUserMedia, setRemoteDescription, setLocalDescription B->>T: STUN binding request, TURN allocate T-->>B: server reflexive and relay candidates B->>S: answer (SDP with fingerprint) S->>A: answer A->>S: ICE candidates (trickle) S->>B: ICE candidates B->>S: ICE candidates (trickle) S->>A: ICE candidates A-->>B: STUN connectivity checks on candidate pairs Note over A,B: best pair nominated, ICE connected A-->>B: DTLS handshake, fingerprints verified Note over A,B: SRTP keys derived, SCTP association up A-->>B: SRTP audio and video B-->>A: SRTP audio and video A-->>B: data channel messages over SCTP

The sequence hides a state machine the application can observe. RTCPeerConnection.signalingState follows the offer/answer exchange (stable, have-local-offer, have-remote-offer, back to stable). iceGatheringState goes from new through gathering to complete. iceConnectionState and the aggregate connectionState go from new through connecting to connected, and may later become disconnected (no recent packets, often recovers), failed (every pair failed, an ICE restart is needed) or closed. A correct client reacts to failed by restarting ICE and to negotiationneeded by creating a new offer, rather than by polling.

The specification map

WebRTC is specified in two organisations. The W3C defines the JavaScript APIs; the IETF defines the protocols the browser speaks on the wire and the overview documents. The table lists the documents a developer meets, with the current numbers.

Area Document What it defines

API

WebRTC 1.0 (W3C Recommendation, 2025-03-13)

RTCPeerConnection, RTCDataChannel, RTCRtpSender, RTCRtpReceiver, RTCRtpTransceiver, RTCIceTransport, RTCDtlsTransport, RTCSctpTransport, getStats()

API

Media Capture and Streams and Screen Capture

getUserMedia(), MediaStreamTrack, constraints, enumerateDevices(), getDisplayMedia()

API

WebRTC Statistics and WebRTC Encoded Transform

the getStats() report types, and RTCRtpScriptTransform for encoded frames

Overview

RFC 8825 (overview), RFC 8826 (security considerations), RFC 8827 (security architecture), RFC 8828 (IP address handling)

the reasoning behind the suite, the threat model, the DTLS fingerprint model, and the rules for exposing local addresses

Session description

RFC 9429 (JSEP, obsoletes RFC 8829), RFC 8866 (SDP, obsoletes RFC 4566), RFC 9143 (BUNDLE, obsoletes RFC 8843)

offer/answer rules in the browser, the text format of descriptions, and one transport for all media

NAT traversal

RFC 8445 (ICE), RFC 8838 (trickle ICE), RFC 8489 (STUN), RFC 8656 (TURN), RFC 7064 and RFC 7065 (STUN and TURN URIs)

candidate gathering and checks, incremental candidates, address discovery, relaying, and the stun: and turn: URL schemes

Security

RFC 5764 (DTLS-SRTP), RFC 6347 (DTLS 1.2), RFC 3711 (SRTP), RFC 9605 (SFrame)

key agreement for media, the handshake, packet protection, and end-to-end frame encryption

Media

RFC 3550 (RTP), RFC 8834 (media transport), RFC 7874 (audio codecs), RFC 7742 (video codecs), RFC 6716 (Opus), RFC 6464 (audio level)

packetisation and reporting, what an endpoint must implement, the mandatory codecs and the level header extension

Simulcast

RFC 8851 (RID), RFC 8853 (simulcast in SDP)

several encodings of one source, selected by an SFU

Data

RFC 8831 (data channels), RFC 8832 (DCEP), RFC 8841 (SCTP in SDP), RFC 8261 (SCTP over DTLS), RFC 9260 (SCTP)

channel semantics and reliability modes, channel opening, negotiation, and transport

Transport overview

RFC 8835 (transports for WebRTC)

how ICE, DTLS, SCTP and RTP fit together

Several mechanisms that every browser implements are not in an RFC. Multicast DNS host candidates (which hide local IP addresses behind random .local names) come from draft-ietf-rtcweb-mdns-ice-candidates, which has expired. Transport-wide congestion control comes from draft-holmer-rmcat-transport-wide-cc-extensions, also expired. The TURN REST API that issues time-limited credentials was never published as an RFC. All three are de facto standards: this section labels them as drafts or conventions and cites RFC 8888 when it talks about congestion feedback.

Two relatives of WebRTC are still moving. WHIP (WebRTC-HTTP Ingestion Protocol) is RFC 9725; its counterpart for playback, WHEP, is still the Internet-Draft draft-ietf-wish-whep (revision 05 at the time of writing). Media over QUIC is the Internet-Draft draft-ietf-moq-transport (revision 22), and WebTransport is a W3C Candidate Recommendation Snapshot. Both are covered as emerging alternatives in WebTransport and Media over QUIC and WHIP, WHEP and Live Streaming, not as recommendations.

Browser support

RTCPeerConnection, data channels, getUserMedia() and getStats() are available in current Chrome and Edge (Chromium), Firefox and Safari on desktop, and in Safari and the other WebKit-based browsers on iOS. Interoperability between engines is good for audio, VP8, H.264 and data channels. The differences you meet in practice are:

  • Video codecs beyond the mandatory two. VP9, AV1 and H.265 availability depends on the browser version, the operating system and the hardware. Always read the real list with RTCRtpReceiver.getCapabilities('video') (the first example below does), and keep H.264 or VP8 as the fallback.

  • Newer APIs. RTCRtpScriptTransform (the standard encoded-transform API) is in current browsers, but Chrome’s older createEncodedStreams() is legacy and MediaStreamTrackProcessor and MediaStreamTrackGenerator do not ship everywhere. Feature-detect each one.

  • Background and iOS behaviour. Mobile browsers pause or throttle capture and media when the tab goes to the background or the screen locks.

  • Shims. adapter.js (the webrtc-adapter package, currently 9.0.6) once papered over prefixed APIs and old behaviours. Version 9 removed most of those shims, and the pages here do not depend on it. Use it, if at all, only to smooth remaining behavioural differences.

Check current compatibility on the MDN compatibility tables for the specific API you use, not on an overview page.

Debugging tools

When a call fails, three places show what the browser negotiated and measured.

  • chrome://webrtc-internals (Chrome and Edge, open it in a separate tab before the call starts) lists every peer connection with its configuration, its event timeline (signalling state, ICE state, each candidate), the full local and remote SDP, and live graphs of every getStats() metric. It can download a dump of all of this as JSON, which is the right attachment for a bug report.

  • about:webrtc (Firefox) shows the same information: the connection log, the SDP, the ICE candidate pairs with their state, and per-stream statistics.

  • Safari has no equivalent page. Enable the Develop menu, then the WebRTC logging options, and read the Web Inspector console, or collect getStats() output in your own code.

The browser-independent tool is getStats() itself. Writing a few lines that log the selected candidate pair, the DTLS state and the codec of each inbound stream (the second example does exactly that) turns a vague "video does not work" into a concrete answer. The Trickle ICE sample page of the WebRTC samples (https://webrtc.github.io/samples/) tests whether a STUN or TURN server hands out the candidates you expect.

How the three scenarios use WebRTC

Feature Chatterbox (chat) Huddle (videoconference) Pixel Arena (game)

Capture (getUserMedia)

not needed

camera, microphone, screen share

optional voice chat

Peer connection

not needed, WebSocket is enough

one per remote peer (mesh) or one to the SFU

one to the game server, or one per player

Signalling

the existing Chatterbox server

a WebSocket relay for offers, answers and candidates

the lobby server of the game

ICE with STUN and TURN

not needed

required, TURN as fallback

required for peer-to-server or peer-to-peer data

Audio and video (SRTP)

not needed

the core feature

optional

Data channels (SCTP)

optional, for peer-to-peer file transfer

chat inside the call, hand-raise, reactions

unordered unreliable channel for input and state

Chatterbox does not need WebRTC: its messages are small, ordered and reliable, which WebSocket serves well, and the chat architecture gains nothing from peer-to-peer. Huddle is the canonical WebRTC application, and the videoconference architecture page shows how mesh and SFU modes put the pieces of this page in different places. Pixel Arena uses data channels with ordered: false and maxRetransmits: 0 when a stale snapshot is worth nothing and TCP’s retransmission would add latency; the game architecture page explains when that beats a plain WebSocket. The decision between the two protocol families is made in Real-Time Web Overview and Comparison and Decision Guide.

Example 1: what this browser can do

Before designing around a feature, ask the browser. This page, saved as capabilities.html, feature-detects the APIs Huddle and Pixel Arena rely on, lists the codecs the browser can receive, and negotiates a data channel against itself to read the SCTP limits. It needs no camera and no server beyond a static file host on localhost.

<!doctype html>
<meta charset="utf-8">
<title>Huddle capability check</title>
<pre id="out"></pre>
<script type="module">
// Feature-detect the WebRTC building blocks Huddle and Pixel Arena rely on.
const report = {
  secureContext: window.isSecureContext,
  peerConnection: 'RTCPeerConnection' in window,
  getUserMedia: !!navigator.mediaDevices?.getUserMedia,
  getDisplayMedia: !!navigator.mediaDevices?.getDisplayMedia,
  dataChannel: 'RTCDataChannel' in window,
  scriptTransform: 'RTCRtpScriptTransform' in window,
  setCodecPreferences: 'setCodecPreferences' in RTCRtpTransceiver.prototype,
  setSinkId: 'setSinkId' in HTMLMediaElement.prototype,
};

const codecNames = (kind) => [...new Set(
  RTCRtpReceiver.getCapabilities(kind).codecs
    .map((c) => c.mimeType.split('/')[1])
    .filter((name) => !['red', 'ulpfec', 'flexfec-03', 'rtx', 'telephone-event', 'CN'].includes(name)),
)];
report.audioCodecs = codecNames('audio');
report.videoCodecs = codecNames('video');

// The SCTP limits are known only once a data channel has been negotiated.
const a = new RTCPeerConnection();
const b = new RTCPeerConnection();
a.onicecandidate = (e) => e.candidate && b.addIceCandidate(e.candidate);
b.onicecandidate = (e) => e.candidate && a.addIceCandidate(e.candidate);
const probe = a.createDataChannel('probe');
await a.setLocalDescription();
await b.setRemoteDescription(a.localDescription);
await b.setLocalDescription();
await a.setRemoteDescription(b.localDescription);
await new Promise((resolve) => { probe.onopen = resolve; });
report.sctpMaxMessageSize = a.sctp.maxMessageSize;
report.sctpMaxChannels = a.sctp.maxChannels;
report.sctpState = a.sctp.state;
a.close();
b.close();

document.getElementById('out').textContent = JSON.stringify(report, null, 2);
window.__report = report;
</script>

Run in Chrome 155, it printed the following (the codec lists and the maximum message size vary by browser).

{
  "secureContext": true,
  "peerConnection": true,
  "getUserMedia": true,
  "getDisplayMedia": true,
  "dataChannel": true,
  "scriptTransform": true,
  "setCodecPreferences": true,
  "setSinkId": true,
  "audioCodecs": ["opus", "G722", "PCMU", "PCMA"],
  "videoCodecs": ["VP8", "VP9", "H264", "AV1", "H265"],
  "sctpMaxMessageSize": 262144,
  "sctpMaxChannels": 65535,
  "sctpState": "connected"
}

Example 2: a loopback call and what getStats reports

This page runs both ends of a call in one tab, which is the quickest way to see every layer of the stack working without a signalling server. It captures the camera and microphone, connects two peer connections by passing the descriptions and candidates between them as function calls (that is what signalling replaces), opens a Pixel Arena style unordered data channel, and then reads getStats() to show the DTLS state, the ciphers, the selected candidate pair and the codec of each received stream.

<!doctype html>
<meta charset="utf-8">
<title>Huddle loopback probe</title>
<video id="remote" autoplay playsinline muted></video>
<pre id="out"></pre>
<script type="module">
const out = document.getElementById('out');
const log = (line) => { out.textContent += line + '\n'; };

// 1. Capture: the "ends" of a call are MediaStreamTracks.
const stream = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: { width: { ideal: 640 }, height: { ideal: 360 }, frameRate: { ideal: 30 } },
});
log(`captured tracks: ${stream.getTracks().map((t) => t.kind).join(', ')}`);

// 2. Two peer connections in one page stand in for two browsers.
//    No iceServers: host candidates are enough on one machine.
const caller = new RTCPeerConnection();
const callee = new RTCPeerConnection();
caller.onicecandidate = (e) => e.candidate && callee.addIceCandidate(e.candidate);
callee.onicecandidate = (e) => e.candidate && caller.addIceCandidate(e.candidate);

// 3. Data channel in game mode: unordered and without retransmissions.
const arena = caller.createDataChannel('pixel-arena-state', { ordered: false, maxRetransmits: 0 });
const received = new Promise((resolve) => {
  callee.ondatachannel = (e) => { e.channel.onmessage = (m) => resolve(m.data); };
});
arena.onopen = () => arena.send(JSON.stringify({ tick: 1, x: 12, y: 34 }));

const remoteStream = new MediaStream();
callee.ontrack = (e) => {
  remoteStream.addTrack(e.track);
  document.getElementById('remote').srcObject = remoteStream;
};
for (const track of stream.getTracks()) caller.addTrack(track, stream);

// 4. Signalling is the application's job; here it is a function call.
await caller.setLocalDescription();
await callee.setRemoteDescription(caller.localDescription);
await callee.setLocalDescription();
await caller.setRemoteDescription(callee.localDescription);

// 5. Wait for the media and the data channel to flow.
const connected = new Promise((resolve) => {
  caller.onconnectionstatechange = () => caller.connectionState === 'connected' && resolve();
});
await connected;
log(`data channel message: ${await received}`);
await new Promise((resolve) => setTimeout(resolve, 2000));

// 6. Read what the stack negotiated from getStats().
const stats = await callee.getStats();
const byId = new Map([...stats.values()].map((s) => [s.id, s]));
const transport = [...stats.values()].find((s) => s.type === 'transport');
const pair = byId.get(transport.selectedCandidatePairId);
const local = byId.get(pair.localCandidateId);
log(`dtlsState=${transport.dtlsState} cipher=${transport.dtlsCipher} srtpCipher=${transport.srtpCipher}`);
log(`candidate pair: ${local.candidateType} ${local.protocol} (state=${pair.state})`);
for (const s of stats.values()) {
  if (s.type === 'inbound-rtp') {
    const codec = byId.get(s.codecId);
    log(`inbound ${s.kind}: codec=${codec.mimeType} packets=${s.packetsReceived} bytes=${s.bytesReceived}`);
  }
}
window.__result = {
  dtlsState: transport.dtlsState,
  candidateType: local.candidateType,
  inbound: [...stats.values()].filter((s) => s.type === 'inbound-rtp' && s.bytesReceived > 0).length,
};
caller.close();
callee.close();
stream.getTracks().forEach((t) => t.stop());
</script>

To run it headless with fake devices, serve the directory over http://localhost (a secure context) and drive Chrome with Puppeteer. The package.json pins the versions used.

{
  "name": "huddle-probes",
  "private": true,
  "type": "module",
  "dependencies": {
    "puppeteer-core": "25.16.0",
    "ws": "8.22.0"
  }
}
// run-loopback.mjs
import puppeteer from 'puppeteer-core';

const browser = await puppeteer.launch({
  executablePath: process.env.CHROME_PATH, // path to your Chrome or Chromium binary
  headless: true,
  args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
});
const page = await browser.newPage();
await page.goto('http://localhost:40000/loopback.html');
await page.waitForFunction('window.__result', { timeout: 30000 });
console.log(await page.evaluate(() => document.getElementById('out').textContent));
await browser.close();
// serve.mjs: static files on http://localhost:40000
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';

const root = process.cwd();
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' };

http.createServer(async (req, res) => {
  const path = normalize(new URL(req.url, 'http://localhost').pathname).replace(/^(\.\.[/\\])+/, '');
  try {
    const body = await readFile(join(root, path === '/' ? 'index.html' : path));
    res.writeHead(200, { 'content-type': types[extname(path)] ?? 'application/octet-stream' });
    res.end(body);
  } catch {
    res.writeHead(404).end('not found');
  }
}).listen(40000, '127.0.0.1', () => console.log('http://localhost:40000'));
npm install
node serve.mjs &
CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" node run-loopback.mjs

The run printed:

captured tracks: audio, video
data channel message: {"tick":1,"x":12,"y":34}
dtlsState=connected cipher=TLS_AES_128_GCM_SHA256 srtpCipher=SRTP_AES128_CM_HMAC_SHA1_80
candidate pair: host udp (state=succeeded)
inbound audio: codec=audio/opus packets=100 bytes=4730
inbound video: codec=video/VP8 packets=79 bytes=55391

Each line maps to the stack in the figure: DTLS with its cipher and the SRTP profile derived from it, ICE with a host candidate pair over UDP, RTP packets decoded as Opus and VP8, and an SCTP data channel that delivered the message. The ciphers and packet counts vary by browser version and run.

Example 3: a two-party Huddle call over a WebSocket relay

The loopback hides the one part that is yours to build. This example adds it: a Node.js server that serves the page and relays signalling messages between the two members of a room, and a page that publishes its camera and microphone, negotiates with the other member and shows the remote video. The server never looks inside description and candidate; it only forwards them. It checks the Origin header (a WebSocket handshake carries cookies and is not subject to the same-origin policy, so every signalling server must allow-list origins), limits the frame size, and validates the room name. The initiator flag tells the second arrival to send the offer, which avoids glare in this two-party demo; the general solution is the perfect-negotiation pattern described in Signaling and Perfect Negotiation.

// huddle-signal.mjs: static page + two-party signalling relay for Huddle
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { WebSocketServer } from 'ws';

const PORT = Number(process.env.PORT ?? 40001);
const ALLOWED_ORIGINS = new Set([`http://localhost:${PORT}`]);
const rooms = new Map(); // room name -> Set of sockets

const server = http.createServer(async (req, res) => {
  if (req.url === '/' || req.url.startsWith('/?')) {
    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
    res.end(await readFile(new URL('./huddle-call.html', import.meta.url)));
  } else {
    res.writeHead(404).end();
  }
});

const wss = new WebSocketServer({
  server,
  path: '/signal',
  maxPayload: 64 * 1024, // an SDP offer is a few KB
  verifyClient: ({ origin }, done) =>
    ALLOWED_ORIGINS.has(origin) ? done(true) : done(false, 403, 'origin not allowed'),
});

wss.on('connection', (ws) => {
  let room = null;
  ws.on('message', (raw) => {
    let msg;
    try { msg = JSON.parse(raw.toString()); } catch { return ws.close(4400, 'bad json'); }

    if (msg.type === 'join' && /^[a-z0-9-]{1,64}$/.test(msg.room ?? '') && room === null) {
      const members = rooms.get(msg.room) ?? new Set();
      if (members.size >= 2) return ws.close(4429, 'room full'); // mesh of two in this demo
      room = msg.room;
      members.add(ws);
      rooms.set(room, members);
      // The second arrival is told to start the negotiation.
      ws.send(JSON.stringify({ type: 'joined', initiator: members.size === 2 }));
      return;
    }
    if (msg.type === 'signal' && room !== null) {
      for (const peer of rooms.get(room)) {
        if (peer !== ws && peer.readyState === peer.OPEN) peer.send(JSON.stringify(msg));
      }
    }
  });
  ws.on('close', () => {
    if (room === null) return;
    const members = rooms.get(room);
    members.delete(ws);
    if (members.size === 0) rooms.delete(room);
  });
});

server.listen(PORT, '127.0.0.1', () => console.log(`Huddle on http://localhost:${PORT}`));
<!doctype html>
<meta charset="utf-8">
<title>Huddle call</title>
<video id="local" autoplay playsinline muted width="320"></video>
<video id="remote" autoplay playsinline width="320"></video>
<script type="module">
const room = new URLSearchParams(location.search).get('room') ?? 'standup';
const pc = new RTCPeerConnection({
  iceServers: [{ urls: 'stun:stun.example.org:3478' }], // placeholder; unused on one machine
});
// Capture and publish.
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
document.getElementById('local').srcObject = stream;
for (const track of stream.getTracks()) pc.addTrack(track, stream);

// Open the signalling socket only after capture, so no message arrives before the handlers exist.
const ws = new WebSocket(`ws://${location.host}/signal`);
const send = (data) => ws.send(JSON.stringify({ type: 'signal', data }));

// Receive.
pc.ontrack = ({ streams: [remote] }) => {
  document.getElementById('remote').srcObject = remote;
};
pc.onicecandidate = ({ candidate }) => candidate && send({ candidate });
pc.onconnectionstatechange = () => {
  document.title = `Huddle ${pc.connectionState}`;
  if (pc.connectionState === 'connected') window.__connected = true;
};

// Signalling: relay offer, answer and candidates; the server never reads them.
ws.onopen = () => ws.send(JSON.stringify({ type: 'join', room }));
ws.onmessage = async ({ data }) => {
  const msg = JSON.parse(data);
  if (msg.type === 'joined' && msg.initiator) {
    await pc.setLocalDescription(); // implicit createOffer
    send({ description: pc.localDescription });
  } else if (msg.type === 'signal') {
    const { description, candidate } = msg.data;
    if (description) {
      await pc.setRemoteDescription(description);
      if (description.type === 'offer') {
        await pc.setLocalDescription(); // implicit createAnswer
        send({ description: pc.localDescription });
      }
    } else if (candidate) {
      await pc.addIceCandidate(candidate);
    }
  }
};
</script>

The page opens its WebSocket only after capture has finished. If it opened the socket first, the server’s joined message could arrive before the handlers were assigned, and the call would never start. The stun.example.org URL is a placeholder: on one machine host candidates are enough, and a real deployment lists its own STUN and TURN servers (with ephemeral TURN credentials, see ICE, STUN and TURN).

To test it, start the server with node huddle-signal.mjs, then open http://localhost:40001/?room=standup in two tabs. Driven from two headless Chrome pages with fake devices, both pages reached the connected state within a second and decoded the remote test video:

title=Huddle connected remote=640x480 playing=true
title=Huddle connected remote=640x480 playing=true

A WebSocket client that presents another origin is refused with HTTP status 403 during the handshake.

What an offer looks like

The offer the Huddle page creates in Example 3 is plain text. The excerpt below, produced by Chrome 155 for one audio track, one video track and one data channel, keeps the lines that map to the parts above and drops the codec lists and header extensions.

v=0
o=- 2078129484467219365 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1 2
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126
c=IN IP4 0.0.0.0
a=ice-ufrag:NV0d
a=fingerprint:sha-256 89:39:65:52:C0:37:D5:0F:AD:42:C4:52:AA:6C:87:DF:93:34:50:0E:96:07:10:E2:40:4E:66:8A:51:2D:7E:28
a=setup:actpass
a=mid:0
a=sendrecv
a=rtcp-mux
a=rtpmap:111 opus/48000/2
m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 104 107
a=mid:1
a=rtpmap:96 VP8/90000
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
a=mid:2
a=sctp-port:5000

UDP/TLS/RTP/SAVPF is the profile name for SRTP keyed by DTLS, and UDP/DTLS/SCTP webrtc-datachannel is the data channel association. a=ice-ufrag is the username fragment for ICE checks, a=fingerprint is the certificate fingerprint that authenticates the DTLS handshake, a=setup:actpass lets the answerer choose the DTLS role, a=mid names each media section, and the BUNDLE group puts all three on one transport. The c= line carries the placeholder address 0.0.0.0 because real addresses arrive as candidates. The full anatomy is on Peer Connections and SDP.

Common errors and pitfalls

  • getUserMedia is undefined. The page is not a secure context (plain HTTP on a non-localhost host) or runs in an iframe without allow="camera; microphone". navigator.mediaDevices does not exist at all in an insecure context.

  • The call connects on a LAN but never across networks. No TURN server is configured, or its credentials are wrong. iceConnectionState ends in failed, and chrome://webrtc-internals shows no relay candidates.

  • Connection works for most users, fails behind some corporate networks. UDP is blocked. Offer TURN over TCP and over TLS on port 443 as a fallback.

  • Black or silent remote video. Remote tracks arrive before the user interacts, and autoplay policy blocks unmuted playback. Start <video> elements with autoplay playsinline and handle the rejected play() promise with a click prompt.

  • Candidates added before the remote description. addIceCandidate() rejects until setRemoteDescription() has completed. Queue candidates that arrive early.

  • Both peers offer at the same time. Without a polite and impolite role, both calls fail with state errors. Use perfect negotiation.

  • Signalling message loss or reordering. A duplicate or missing offer leaves the peers in have-local-offer forever. Signalling must be reliable and ordered per peer.

  • Treating disconnected as final. It frequently recovers. React to failed, and restart ICE with restartIce().

  • Trusting the signalling server for identity. The DTLS fingerprint proves only that the media peer is the one that produced the SDP, not who it is. Authenticate users in signalling, in the way described in Authentication and Authorization.

Security notes

  • Media is always encrypted with DTLS-SRTP, and data channels with DTLS. Through an SFU the encryption is hop by hop, so the server can read the media unless SFrame end-to-end encryption is added.

  • A page that can call getUserMedia() can record the user. Permissions are per origin, a visible indicator is shown by the browser while capture is active, and the Permissions-Policy header controls which frames may ask.

  • Without mDNS obfuscation, ICE candidates expose local IP addresses. Browsers hide them behind .local names until the user grants capture permission, and RFC 8828 sets the rules for exposing them.

  • TURN servers relay traffic for anyone with credentials. Credentials must be ephemeral (an HMAC of an expiry timestamp and user name with a shared secret), never a static password shipped to the browser, and the relay must refuse peer addresses on private networks to prevent server-side request forgery.

  • The signalling server is the trust anchor: whoever can alter an SDP can swap the fingerprint and sit in the middle. Run signalling over wss, authenticate the sender, validate message shape and size, and check Origin.

  • Browsers expose many WebRTC knobs to scripts. Treat anything received from a peer (data channel messages, SDP, track metadata) as untrusted input.

Version notes

  • Specifications. JSEP is RFC 9429 (it obsoletes RFC 8829) and BUNDLE is RFC 9143 (it obsoletes RFC 8843). The API behaviour did not change, so code written against the older numbers keeps working.

  • SDP. Unified Plan is the only format; Plan B is gone. Check any tutorial that talks about streams in one media section.

  • API. Use addTrack(), transceivers and promises. setLocalDescription() without an argument creates the offer or answer itself.

  • adapter.js. Version 9 removed almost all shims; the library is optional on current browsers.

  • Encoded media. Chrome’s createEncodedStreams() is the legacy API. RTCRtpScriptTransform, run in a worker, is the standard one (W3C WebRTC Encoded Transform).

  • Baseline. W3C WebRTC 1.0 edition of 2025-03-13; the examples ran on Chrome 155 and Node.js 24.

History and specification status

The first browser implementations shipped in Chrome and Firefox in 2012 and 2013, at first with prefixed names and incompatible SDP dialects. The IETF RTCWEB working group published the protocol suite (RFC 8825 to RFC 8835 among them) in 2021, after most of it had been deployed for years, and W3C WebRTC 1.0 became a Recommendation on 2021-01-26, with later editions folding in tested amendments (the current one is dated 2025-03-13). The transition from the older Plan B SDP to Unified Plan, the replacement of callback-style APIs with promises, the arrival of the implicit setLocalDescription() (no argument), the replacement of RFC 8829 by RFC 9429 in 2024, and the removal of most adapter.js shims are the changes that make older tutorials misleading. Treat anything that uses addStream(), createOffer() callbacks, webkitRTCPeerConnection or Plan B semantics as outdated.

References