WebRTC Architecture

WebRTC is a set of browser APIs and network protocols for direct connections between two endpoints. A browser opens a connection to another browser and sends audio, video, or arbitrary data over it. The data path runs peer-to-peer: once the connection is up, bytes travel between the two peers without passing through an application server.

This is the architectural shift. A classic web request is client-to-server: the browser asks a server for a resource and the server answers. WebRTC adds a second model where the browser is itself an endpoint that other browsers connect to. The server, if any, helps the two peers find each other and then steps out of the way.

Getting two browsers to talk directly is hard. Neither has a stable public address. Both usually sit behind a NAT or firewall that drops unsolicited inbound packets. Network quality changes as a device moves between networks. WebRTC is not one protocol. It is a stack of protocols, each solving one part of that problem, layered so that each depends on the one below it.

This article maps the whole stack: the objects you create in JavaScript, the engines they drive inside the browser, the protocols they negotiate on the wire, and the topologies you build when more than two peers join. Code examples use this repo's wrappers in src/salon/, which are thin and map one-to-one to the underlying API.

The three core objects

The JavaScript API exposes three objects. Each maps to a different responsibility. You almost always start with RTCPeerConnection and attach the other two to it.

Object Carries Created by Role
RTCPeerConnection n/a new RTCPeerConnection(config) Owns the connection: ICE, DTLS, state, negotiation.
MediaStream Audio / video tracks getUserMedia / getDisplayMedia Media captured from camera, mic, or screen.
RTCDataChannel Arbitrary bytes pc.createDataChannel(label) Bidirectional message channel, like a WebSocket between peers.

RTCPeerConnection is the central object. It holds the network path, the encryption keys, the negotiation state, and the list of tracks and channels. MediaStream and RTCDataChannel are payloads attached to it: a connection can carry either, both, or neither.

This repo uses only the data channel. Game input and state move over RTCDataChannel; there is no media. The media APIs are covered here for completeness because the architecture is the same whether you send video or JSON.

MediaStreamaudio / video tracks RTCDataChannelgame input / state RTCPeerConnectionowns negotiation and the single transport ICE + DTLSone shared path: NAT traversal, then encryption PAYLOADS

RTCPeerConnection

RTCPeerConnection is the connection. Constructing one allocates an ICE agent, generates a DTLS certificate, and prepares the internal state machine. The constructor takes one configuration object; the field you always set is iceServers.

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
  ],
});

iceServers is a list of STUN (and, in other setups, TURN) servers the ICE agent queries to learn its public address. This repo lists public STUN servers only and never a TURN relay. See ICE & NAT for what those servers do.

The object's API splits into four groups.

Negotiation methods produce and apply the session descriptions (SDP) that the two peers exchange:

Method Returns Purpose
createOffer() RTCSessionDescriptionInit Build the initiating description.
createAnswer() RTCSessionDescriptionInit Build the responding description.
setLocalDescription(desc) Promise<void> Apply your own description; starts ICE gathering.
setRemoteDescription(desc) Promise<void> Apply the other peer's description.
addIceCandidate(cand) Promise<void> Add a candidate received out-of-band (trickle ICE).

Payload methods attach media and data:

Method Purpose
addTrack(track, stream) Add an outgoing audio/video track.
removeTrack(sender) Stop sending a track.
createDataChannel(label, init) Open a data channel from this side.
getSenders() / getReceivers() Inspect the track senders and receivers.

State and inspection:

Property / method Purpose
connectionState Aggregate connection state (newconnectedclosed).
iceConnectionState ICE-specific state.
iceGatheringState Candidate gathering progress (new / gathering / complete).
signalingState Where the offer/answer exchange stands.
localDescription / remoteDescription The applied SDP.
getStats() Live metrics: bitrate, packet loss, round-trip time.
close() Tear down the connection and free its resources.

Events fire as the connection progresses:

Event Fires when
onicecandidate A candidate is gathered (trickle ICE).
ondatachannel The remote peer opened a channel.
ontrack The remote peer added a media track.
onconnectionstatechange connectionState changed.
oniceconnectionstatechange iceConnectionState changed.
onnegotiationneeded A change requires a new offer/answer round.

The full lifecycle of connectionState and signalingState is its own subject. See the state machine. Reference detail lives in RTCPeerConnection.

MediaStream and tracks

A MediaStream is a container of tracks. A track is one audio or video source: a microphone, a camera, a screen capture. getUserMedia asks the browser for camera and mic access; getDisplayMedia asks for a screen or window.

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

You do not send a MediaStream over the connection. You add its individual tracks with addTrack, and the receiving side reassembles them:

for (const track of stream.getTracks()) {
  pc.addTrack(track, stream);
}

pc.ontrack = (e) => {
  remoteVideo.srcObject = e.streams[0];
};

addTrack returns an RTCRtpSender, which exposes replaceTrack (swap camera without renegotiating) and getParameters / setParameters (cap bitrate, change encoding). Adding or removing a track usually fires onnegotiationneeded, because the SDP must be re-exchanged to describe the new media. This repo does not use media, so it never hits that path.

RTCDataChannel

An RTCDataChannel is a bidirectional channel for arbitrary messages (strings or binary). It behaves like a WebSocket, except the bytes travel peer-to-peer and never reach a server. This is the only payload this repo uses.

One side calls createDataChannel; the other receives it through ondatachannel:

// Initiator
const ch = pc.createDataChannel('game');
ch.onopen = () => ch.send(JSON.stringify({ t: 'hello' }));

// Responder
pc.ondatachannel = (e) => {
  const ch = e.channel;
  ch.onmessage = (m) => handle(JSON.parse(m.data));
};

The channel's surface:

Member Purpose
send(data) Send a string, ArrayBuffer, or Blob.
readyState connecting / open / closing / closed.
onopen / onclose / onmessage / onerror Lifecycle and inbound data.
label The string passed to createDataChannel.
bufferedAmount Bytes queued but not yet sent: backpressure signal.

The init argument to createDataChannel tunes delivery:

Option Effect
ordered: false Allow out-of-order delivery (lower latency).
maxRetransmits: N Give up after N resends: partial reliability.
maxPacketLifeTime: ms Give up after a time budget instead of a retry count.

By default a channel is reliable and ordered, like TCP. Setting ordered: false with maxRetransmits: 0 makes it unreliable and unordered, like UDP: the right mode for game state you overwrite every frame, where a dropped packet is cheaper than a delayed one. The repo's defaults are reliable; per-game tuning happens at createDataChannel. See protocols for how this maps to SCTP on the wire.

The protocol stack

A WebRTC connection is built in layers. Each layer depends on the one below it. The order is fixed: you cannot encrypt before you have a path, and you cannot send media before you have keys.

  1. Signaling: out-of-band. The two peers exchange session descriptions (SDP) and candidate addresses through a channel you provide. WebRTC does not specify how. This repo uses manual paste, a URL fragment, or ntfy.sh pub/sub. Signaling carries setup metadata only, never media or game data. See signaling.
  2. ICE: path discovery. Each peer gathers candidate addresses (local interfaces, and public addresses learned from a STUN server) and probes pairs until one connects. This is how the connection crosses NATs. See ICE & NAT.
  3. DTLS: key exchange and encryption. Once a path exists, the peers run a DTLS handshake over it to agree on keys. WebRTC encryption is mandatory; there is no unencrypted mode. See security.
  4. SRTP / SCTP: the payload. Media rides SRTP, keyed by the DTLS handshake. Data channels ride SCTP tunnelled inside DTLS. Both reuse the single ICE-negotiated path.
Signaling is your responsibility

WebRTC defines steps 2 to 4. Step 1 is left to you. The browser produces the SDP and candidates; moving them to the other peer is application code. Any transport works: a server, a chat message, a QR code.

Why these layers exist

Each layer answers one question. Signaling answers "what does the other peer want and where might it be?" ICE answers "which of those addresses actually works?" DTLS answers "how do the peers agree on keys nobody else has?" SRTP/SCTP answers "how does the payload arrive reliably and in order, or not?"

Splitting the work this way lets each layer change independently. You can swap the signaling transport without touching ICE. You can tune data channel reliability without touching DTLS. The repo's three signaling modes (paste, URL, ntfy) all feed the same ICE/DTLS/SCTP machinery underneath; only the transport for the SDP differs.

Mapping the API to the stack

The objects you create in JavaScript drive engines inside the browser. Calling a method on RTCPeerConnection triggers native machinery you never touch directly.

Layer JavaScript API Internal engine Responsibility
Media getUserMedia Voice / Video engine Capture, echo cancellation, jitter buffering, encoding.
Security setLocalDescription DTLS / SRTP Certificates and encrypted tunnels.
Transport createDataChannel SCTP / ICE agent Hole-punching, congestion control, delivery.
JAVASCRIPT API LAYER C++ INTERNAL ENGINES SECURITY & MULTIPLEXING NETWORK TRANSPORT getUserMedia RTCPeerConnection RTCDataChannel Voice / Video Enginehardware encoding JSEP State Machineorchestrator SCTP / ICE Agentcongestion control SRTPmedia encryption DTLSdata encryption + keys UDP Sockets STUN / TURNNAT traversal

From zero to a working connection

This section walks the full flow once, in order, with the real API. Two peers reach a connected data channel. One is the host (initiator), the other the guest (responder). A signaling channel (anything that moves a string from one to the other) sits between them.

Step 1: both peers construct a connection

Each side builds an RTCPeerConnection. The repo's setupPeer wraps the constructor and wires the two events you always need: connection-state changes and incoming channels.

export function setupPeer({ onStateChange, onChannel } = {}) {
  const pc = new RTCPeerConnection({ iceServers: ICE });
  pc.onconnectionstatechange = () => onStateChange?.(pc.connectionState, pc);
  pc.ondatachannel = (e) => onChannel?.(e.channel);
  return pc;
}

At this point nothing is connected. The ICE agent exists, the DTLS certificate is generated, and connectionState is new.

Host(initiator) Guest(responder) signaling carries the offer signaling carries the answer createOffer + setLocalDescription setRemoteDescription(offer) createAnswer + setLocalDescription setRemoteDescription(answer) DTLS handshakekeys derived, certificates verified against the SDP fingerprint data channel openonopen fires, gameplay begins SIGNALING COMPLETE: BOTH SIDES STABLE

Step 2: the host creates the offer

The host opens the data channel first, then builds an offer describing the connection. Opening the channel before createOffer matters: it puts the SCTP data-channel description into the SDP, so the offer advertises a channel from the start.

export async function createInvite(pc, { dataChannel = 'game', channelInit, timeoutMs = 4000 } = {}) {
  const ch = pc.createDataChannel(dataChannel, channelInit);
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  await waitIce(pc, timeoutMs);
  const code = JSON.stringify(pc.localDescription);
  return { code, channel: ch };
}

setLocalDescription starts ICE gathering. waitIce resolves once gathering finishes, or after a timeout, because the candidates gathered so far are usually enough to connect:

export function waitIce(pc, timeoutMs = 4000) {
  if (pc.iceGatheringState === 'complete') return Promise.resolve();
  return new Promise(resolve => {
    const onChange = () => {
      if (pc.iceGatheringState === 'complete') resolve();
    };
    pc.addEventListener('icegatheringstatechange', onChange);
    setTimeout(resolve, timeoutMs);
  });
}

Waiting for gathering to finish before serializing the description is non-trickle ICE: the offer carries all candidates inline, so a single string fully describes the host's side. The alternative, trickle ICE, ships candidates as they arrive via onicecandidate and addIceCandidate, which connects faster but needs a live signaling channel. Paste-based signaling cannot trickle, so the repo waits and bundles. See signaling for the tradeoff.

The returned code is the offer string. The host sends it to the guest over the signaling channel.

Step 3: the guest answers

The guest applies the host's offer, builds an answer, and gathers its own candidates the same way:

export async function acceptInvite(pc, offerCode, { timeoutMs = 4000 } = {}) {
  await pc.setRemoteDescription(JSON.parse(offerCode));
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  await waitIce(pc, timeoutMs);
  return JSON.stringify(pc.localDescription);
}

The guest does not call createDataChannel. The offer already advertised the channel, so the guest receives it through ondatachannel once the connection opens. acceptInvite returns the answer string, which the guest sends back to the host.

Step 4: the host applies the answer

export async function completeInvite(pc, answerCode) {
  await pc.setRemoteDescription(JSON.parse(answerCode));
}

Both sides now hold each other's description and candidates. ICE probes the candidate pairs, picks a working path, and DTLS runs its handshake over it. The data channel transitions to open on both sides, and connectionState reaches connected.

What the offer and answer actually contain

The string the peers exchange is SDP (Session Description Protocol), a line-based text format. You rarely read it by hand, but knowing its shape explains why the flow works. Each line is key=value. The offer for a data-channel-only connection looks roughly like this:

v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:F7gI
a=ice-pwd:x9cm/2x...
a=fingerprint:sha-256 12:34:56:...
a=setup:actpass
a=mid:0
a=sctp-port:5000
a=candidate:842163049 1 udp 1677729535 203.0.113.7 50000 typ srflx ...

Four blocks matter for the architecture:

SDP element Layer it feeds What it carries
m=application … SCTP Transport This connection carries a data channel, not media.
a=ice-ufrag / a=ice-pwd ICE Credentials the two agents use to authenticate connectivity checks.
a=candidate:… ICE A gathered address to try (typ srflx = a public address learned from STUN).
a=fingerprint / a=setup DTLS The cert fingerprint each side commits to, and which side acts as DTLS client.

The a=candidate lines only appear inline because waitIce ran first. With trickle ICE the offer ships without them and they arrive later as separate RTCIceCandidate objects. A media connection adds an m=audio or m=video block per track, each describing codecs and SRTP parameters. See RTCSessionDescription and RTCIceCandidate for the field-level reference, and protocols for how setup:actpass resolves into a DTLS client/server role.

Step 5: attach handlers and send

attachChannel binds the channel's events. Inbound data is parsed as JSON; malformed frames are dropped silently rather than throwing:

export function attachChannel(ch, { onMessage, onOpen, onClose } = {}) {
  ch.onmessage = (e) => {
    let msg;
    try { msg = JSON.parse(e.data); } catch { return; }
    onMessage?.(msg, ch);
  };
  ch.onopen = () => onOpen?.(ch);
  ch.onclose = () => onClose?.(ch);
  return ch;
}

From here, ch.send(JSON.stringify(msg)) moves bytes peer-to-peer. No server sees them. The echo demo runs this exact flow between two pages in one browser. Debugging covers what to check when a step stalls.

The full sequence, condensed:

# Host Guest
1 setupPeer() setupPeer()
2 createInvite → offer string n/a
3 (send offer) acceptInvite(offer) → answer string
4 completeInvite(answer) (send answer)
5 ICE + DTLS, channel opens ICE + DTLS, channel opens

Carrying data over the channel

A data channel moves bytes; it does not impose a message format. The repo's convention is one JSON object per message, with a short t field naming the type. protocol.js wraps that convention with three helpers.

send guards on channel state so a closed channel never throws:

export function send(channel, msg) {
  if (channel?.readyState !== 'open') return false;
  channel.send(JSON.stringify(msg));
  return true;
}

broadcast fans one message out to many channels. The host uses it to push state to every guest:

export function broadcast(channels, msg) {
  let n = 0;
  for (const ch of channels) {
    if (send(ch, msg)) n++;
  }
  return n;
}

dispatcher turns a { type: handler } map into a single onMessage callback. It reads msg.type or the short msg.t, so in-game traffic and hub control messages can share one channel without colliding:

export function dispatcher(handlers) {
  return (msg, channel) => {
    const key = msg?.type ?? msg?.t;
    const fn = key !== undefined ? handlers[key] : undefined;
    if (fn) fn(msg, channel);
  };
}

Wiring it together, a peer registers handlers by type and hands the dispatcher to attachChannel:

attachChannel(ch, {
  onMessage: dispatcher({
    fire:   (m) => board.resolve(m.cell),
    result: (m) => board.mark(m.cell, m.kind),
  }),
});

Reliable versus unreliable channels

The default channel is reliable and ordered: every message arrives, in send order, retransmitted until acknowledged. That is correct for turn-based events (a chat line, a move, a file chunk) where losing one is unacceptable.

Real-time state is different. A position broadcast every frame is stale the moment the next one is sent. Retransmitting a dropped one wastes time and head-of-line-blocks the messages behind it. For that traffic, configure the channel as unreliable and unordered at creation:

const state = pc.createDataChannel('state', {
  ordered: false,
  maxRetransmits: 0,
});
Mode ordered maxRetransmits Behaves like Use for
Reliable ordered (default) true unset TCP Events, chat, file transfer, turns.
Unreliable unordered false 0 UDP Per-frame position/state you overwrite.
Partial reliability false N or maxPacketLifeTime bounded retry State that tolerates some loss but not total.

A connection can hold several channels at once with different reliability, all over the same ICE path: a reliable game channel for events and an unreliable state channel for movement. The two channels are independent SCTP streams inside the one DTLS tunnel. See protocols for the SCTP stream model.

Backpressure

send returns immediately; it does not block until the bytes leave. Unsent bytes queue in bufferedAmount. If you send faster than the link drains, that number climbs and latency grows. For bulk transfers, check it before sending more:

if (ch.bufferedAmount < 1_000_000) {
  ch.send(chunk);
}
ch.onbufferedamountlow = () => pump();   // fires when the queue drains

Per-frame game state rarely hits this: each message is small and the next overwrites the last. Bulk transfers (a file, a level) do, so they pace against bufferedAmount and onbufferedamountlow.

Topologies

A single RTCPeerConnection connects exactly two endpoints. Any session with more than two peers is built from several pairwise connections. How you arrange them is the topology, and it decides how the session scales.

One-to-one

Two peers, one connection. Each side owns one RTCPeerConnection. This is the baseline and the simplest case to reason about. The whole flow above is one-to-one.

Host-and-spokes (star)

One peer is the host. Every other peer connects to the host and only to the host. The host runs N connections; each guest runs one. Guests never talk to each other; everything routes through the host.

This repo uses the star. peer-pool.js gives the host an array of connections, one per guest, indexed by slotIdx. The host builds each slot, sends an offer per slot, and applies each guest's answer back into the matching slot:

export function peerPool(size, { onSlotState, onSlotMessage } = {}) {
  const pcs = new Array(size).fill(null);
  const channels = new Array(size).fill(null);

  async function inviteSlot(slotIdx, opts) {
    const pc = new RTCPeerConnection({ iceServers: ICE });
    pcs[slotIdx] = pc;
    const ch = pc.createDataChannel(opts?.dataChannel ?? 'game');
    channels[slotIdx] = ch;
    attachChannel(ch, { onMessage: (m, c) => onSlotMessage?.(slotIdx, m, c) });
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    await waitIce(pc, opts?.timeoutMs ?? 4500);
    return JSON.stringify(pc.localDescription);
  }

  async function acceptAnswer(slotIdx, answerCode) {
    await pcs[slotIdx].setRemoteDescription(JSON.parse(answerCode));
  }

  function broadcast(msg) {
    return wireBroadcast(channels.filter(c => c?.readyState === 'open'), msg);
  }

  return { pcs, channels, inviteSlot, acceptAnswer, broadcast, /* … */ };
}

broadcast sends one message to every open channel; send(slotIdx, msg) targets one guest. A guest sends its input to the host; the host resolves it and broadcasts the result back to all slots.

Host G1 G2 G3 P1 P2 P3 P4 STAR: HOST AUTHORITATIVE FULL MESH: EVERY PAIR 3 connections, no guest-to-guest 6 connections for 4 peers

The cost is asymmetric. With G guests the host holds G connections and forwards every message; each guest holds one. The host's uplink is the bottleneck. For a few players this is fine, and it keeps game logic in one place: the host is authoritative, so there is no conflicting state to reconcile across peers.

Full mesh

Every peer connects to every other peer. For N peers that is N·(N−1)/2 connections, and each peer maintains N−1 of them. There is no central node and no single point of failure, but the connection count grows quadratically and every peer uploads to every other.

Choosing a topology

Topology Connections Per-peer connections Host uplink (msgs/tick) Scales to
One-to-one 1 1 1 2
Star (host) N−1 host: N−1, guest: 1 N−1 small groups
Full mesh N·(N−1)/2 N−1 each n/a (no host) a handful

Concretely, for 5 peers a star needs 4 connections; a mesh needs 10. For 10 peers, 9 versus 45. The star trades a busy host for far fewer connections and one authoritative copy of the state. The mesh trades that for symmetry and resilience, at a connection count that stops being practical past a handful of peers.

Larger sessions usually route media through a central forwarding server (an SFU). That server sits in the media path, which this repo does not do. It is out of scope here.

Limits and what is out of scope

WebRTC connects most peer pairs with STUN alone. Some cannot. When both peers sit behind symmetric NAT, neither can predict the other's public port, and STUN-only hole-punching fails. The standard answer is a TURN relay that forwards traffic for both peers.

This repo is STUN-only by design. It uses public STUN servers and no relay. A TURN relay would put a server in the data path, which the project forbids. Pairs that need TURN will not connect here, and that is an accepted limitation, not a bug. ICE & NAT explains which NAT types fail and why.

No server in the data path

Signaling may use a server to exchange SDP. Game data never does. Once ICE finds a path, every message travels peer-to-peer over the encrypted data channel.

Inspecting a live connection

pc.getStats() returns a snapshot of metrics. It is the same data chrome://webrtc-internals graphs, available to your own code. Each entry is a report keyed by type; the useful ones for a data-channel connection are candidate-pair (which path is in use, and its round-trip time) and data-channel (message and byte counts).

const stats = await pc.getStats();
for (const report of stats.values()) {
  if (report.type === 'candidate-pair' && report.state === 'succeeded') {
    console.log('rtt', report.currentRoundTripTime);
  }
  if (report.type === 'data-channel') {
    console.log('sent', report.messagesSent, 'recv', report.messagesReceived);
  }
}

The selected candidate-pair also tells you whether the path is direct or relayed. With this repo's STUN-only setup the pair is always direct (host or srflx candidates); a relay candidate would mean TURN, which the project does not configure.

Troubleshooting the flow

When a connection never reaches connected, the failing step is usually identifiable from the state it stalls in.

Symptom Likely cause Where to look
iceGatheringState stays gathering, no candidates STUN unreachable or blocked Network/firewall; ICE server list
Candidates gathered, connectionState stuck at connecting No working candidate pair, often symmetric NAT both sides ICE & NAT
connectionState reaches failed after connecting DTLS handshake failed, or all pairs timed out security, state machine
Channel never fires onopen, but peers are connected createDataChannel called after the offer, so it is missing from SDP Open the channel before createOffer
onmessage never fires Sending while readyState !== 'open', or sending non-JSON the receiver drops send guard; attachChannel parse step
Works locally, fails across networks Only host candidates exchanged; no public (srflx) candidate Confirm STUN is reachable

The two architecture-level rules behind most of these: open the data channel before creating the offer so it lands in the SDP, and never send before readyState is open. The repo's createInvite and send enforce both. Debugging walks chrome://webrtc-internals for the rest.

Recap

  • WebRTC makes the browser an endpoint other browsers connect to directly.
  • Three objects: RTCPeerConnection (the connection), MediaStream (media tracks), RTCDataChannel (data). This repo uses the data channel only.
  • The stack is layered and ordered: signaling, then ICE, then DTLS, then SRTP/SCTP. You supply signaling; the browser does the rest.
  • The connection flow is offer → answer → apply, then ICE and DTLS open the channel. createInvite / acceptInvite / completeInvite in handshake.js are this flow.
  • setupPeer / waitIce / attachChannel in peer.js map directly to building the connection, finding a path, and attaching a channel.
  • Topology decides scale. This repo uses a host-and-spokes star (peer-pool.js, slotIdx), not a mesh.
  • STUN-only means symmetric-NAT pairs may fail to connect; TURN would fix it but is out of scope.

Going further