WebRTC & WebTransport

A video call where the two people are one room apart shouldn’t route every frame through a server in another country. A multiplayer game can’t wait for a reliable, in-order channel to re-send a dropped position update — by the time it arrives, the player has already moved. Both cases want something plain HTTP and even WebSocket don’t give you: a choice about reliability, and sometimes a path that skips the server entirely.

Two APIs cover this ground. WebRTC connects browsers directly to each other for peer-to-peer audio, video, and arbitrary data. WebTransport is a newer client-to-server channel that runs over HTTP/3 and lets you send unreliable datagrams alongside reliable, independently-flowing streams. They solve different problems, and the fastest way to understand each is to see what it’s built on.

WebRTC: connecting two browsers directly

The headline feature of WebRTC is that data can travel directly between two browsers, without passing through your server. Lower latency, and your infrastructure doesn’t carry the media bytes. But two browsers on home networks can’t just find each other — each one is usually behind a router doing NAT (Network Address Translation), with no public address a stranger can dial. Getting them connected is the hard part, and WebRTC splits that work into two halves.

Your job: signallingMove offers, answers, and ICE candidates between the peers —over WebSocket, fetch, or anything you like. WebRTC does notdefine this. It is your plumbing.The browser’s job: connectivity (ICE)Gather candidate addresses, probe them with STUN, fall back to aTURN relay if a direct path fails, pick the best working pair, andcarry the encrypted media and data once the link is live.
WebRTC responsibilities split in two: you build the signalling channel to swap setup info; the browser's ICE agent handles the actual connectivity through NAT.

The important thing to sit with: WebRTC does not ship a signalling protocol. It hands you strings to exchange and expects you to move them. Any channel works — a WebSocket, a couple of fetch calls, even copy-paste. That flexibility trips people up, because “set up a call” turns out to require a small server of your own just to introduce the peers.

The offer / answer dance

Each side of a connection is an RTCPeerConnection. One peer starts as the caller and creates an offer — a blob of SDP (Session Description Protocol) text describing what it wants to send and the codecs it supports. The other peer, the callee, replies with an answer. You relay both across your signalling channel.

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

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: "offer", sdp: offer.sdp });

createOffer() returns a description; setLocalDescription() commits it and, as a side effect, kicks the browser’s ICE agent into gathering network candidates. The callee does the mirror image:

// Callee, on receiving the offer
const pc = new RTCPeerConnection({ iceServers: [/* ... */] });
await pc.setRemoteDescription(offer);

const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signaling.send({ type: "answer", sdp: answer.sdp });

Back on the caller, receiving that answer finishes the description exchange with await pc.setRemoteDescription(answer). Now both sides know what the other wants to send. What they still don’t know is how to reach each other.

ICE, STUN, and TURN: finding a path through NAT

While the SDP travels, the ICE agent (Interactive Connectivity Establishment) is busy collecting candidates — possible addresses this peer might be reachable on. There are a few kinds:

  • Host candidates: the machine’s own local addresses (like 192.168.1.20). Useful only if both peers are on the same network.
  • Server-reflexive candidates: your public address as seen from the outside, discovered by asking a STUN server “what address did my packet arrive from?” This is what lets two peers behind different routers find a direct path.
  • Relay candidates: an address on a TURN server that will forward traffic for you when no direct path exists. Slower and it costs you bandwidth, but it works when everything else fails.

Each candidate your browser finds fires an icecandidate event. You forward it to the other peer, who feeds it in with addIceCandidate(). This trickle happens continuously, in parallel with the offer/answer, so the connection comes up as soon as any candidate pair works.

pc.addEventListener("icecandidate", (event) => {
  if (event.candidate) {
    signaling.send({ type: "candidate", candidate: event.candidate });
  }
});

// On the other side, when a candidate arrives over signalling:
await pc.addIceCandidate(incoming.candidate);
Peer ASignalling · STUN · TURNPeer Boffer (SDP) via signalling →← answer (SDP)STUN: what’s my public addr? →← srflx candidatetrickle candidates (both ways) →ICE connectivity checks: probe every candidate pairpick the first that answers — prefer direct, fall back to TURN relayencrypted media + data — peer-to-peer, skips the server(relayed through TURN instead, if no direct pair worked)
Establishing a WebRTC link. Signalling (your channel) carries the offer, answer, and candidates. STUN reveals each peer's public address; if a direct path fails, TURN relays. The media path is peer-to-peer once it is up.

Pulling in the camera and mic

For a call you need media, and that comes from getUserMedia. It prompts the user for permission and resolves to a MediaStream whose tracks you add to the connection.

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

// Show your own preview
localVideo.srcObject = stream;

// Send each track to the peer
for (const track of stream.getTracks()) {
  pc.addTrack(track, stream);
}

On the far side, tracks the peer sends surface through a track event, and you attach them to a <video>:

pc.addEventListener("track", (event) => {
  remoteVideo.srcObject = event.streams[0];
});

The data channel: arbitrary bytes, your reliability rules

Media is only half of WebRTC. RTCDataChannel gives you a peer-to-peer pipe for anything — game state, file chunks, chat, cursor positions. You open one from either peer:

const channel = pc.createDataChannel("game", {
  ordered: false,
  maxRetransmits: 0,
});

channel.addEventListener("open", () => channel.send("ready"));
channel.addEventListener("message", (e) => applyUpdate(e.data));

The other peer receives it through a datachannel event:

pc.addEventListener("datachannel", (event) => {
  const channel = event.channel;
  channel.addEventListener("message", (e) => applyUpdate(e.data));
});

The interesting knobs are ordered and the retransmit controls. Under the hood a data channel runs over SCTP, which — unlike TCP — lets you dial reliability down. Two options tune it, and you may set at most one of them (passing both throws a TypeError):

  • maxRetransmits — how many times to re-send a lost message before giving up.
  • maxPacketLifeTime — how many milliseconds to keep trying before giving up.
Reliable + ordered (default)ordered: true — no retransmit limit1232 dropped → 3 waits, 2 re-sent123arrives complete, in order — but delayedUnreliable + unorderedordered: false — maxRetransmits: 01232 dropped → skip it, don’t wait13newest data, minimal latency — 2 is just gone
A data channel's reliability spectrum. Leave the defaults for a TCP-like reliable, ordered channel; drop ordering and retransmits for a UDP-like fast one where late data is worthless.

For a shooter, “unreliable + unordered” is exactly right: a position from 80ms ago is useless once a newer one exists, so re-sending it would only delay the fresh one. For file transfer you want the default reliable, ordered mode. The point is that WebRTC lets you choose, which TCP-based transports can’t.

WebTransport: low latency to your server over HTTP/3

WebRTC is peer-to-peer and complex. Plenty of real-time apps don’t need peers to talk directly — they just need a fast, flexible pipe to your server. That’s WebTransport’s slot. It’s a client-to-server API built on HTTP/3, which itself runs over QUIC, a transport on top of UDP.

Why does the underlying protocol matter here? Because QUIC fixes the flaw that bites WebSocket under packet loss: head-of-line blocking.

Head-of-line blocking, and why HTTP/3 dodges it

A WebSocket is a single TCP connection. TCP guarantees in-order delivery, which sounds good until one packet is lost. Everything sent after the lost packet has to wait in a buffer until the missing one is re-sent — even if those later bytes belong to a completely unrelated message. One dropped packet stalls the whole pipe.

QUIC runs many independent streams over one connection. A loss on stream A holds up only stream A; streams B and C keep flowing. That’s the multiplexing WebSocket never had.

WebSocket — one TCP streamA1B1A2C1A3B1 lostA2, C1, A3 all blockedHTTP/3 / QUIC — independent streamsAA1A2A3✓ flowingBB1← only B waits for its own re-sendCC1C2✓ flowing
One lost packet on a single WebSocket stalls everything behind it. QUIC's independent streams isolate the loss — unaffected streams keep delivering.

The API shape

You open a connection with a URL. ready resolves once it’s usable; closed resolves (or rejects) when it ends.

const transport = new WebTransport("https://example.com:4433/live");
await transport.ready;

From there you have two ways to send, matching the two things a real-time app usually wants.

Reliable streams — like a WebRTC data channel in its default mode, but to a server. Create one, get a writable/readable pair, and use the Streams API to move bytes. Each stream is independent, so several can be in flight without blocking each other.

const stream = await transport.createBidirectionalStream();

const writer = stream.writable.getWriter();
await writer.write(new TextEncoder().encode("hello"));
await writer.close();

const reader = stream.readable.getReader();
const { value } = await reader.read();

Datagrams — unreliable, unordered, no delivery guarantee. There’s no per-message stream object; you read and write through the single datagrams duplex. This is the “send it and move on” path for game ticks or live telemetry.

const writer = transport.datagrams.writable.getWriter();
await writer.write(new Uint8Array([0x01, 0x02]));

const reader = transport.datagrams.readable.getReader();
const { value } = await reader.read(); // a Uint8Array, or nothing — no guarantee
datagram — unreliable, fastclientserverwrite(bytes) — no ack, no retryif it drops, it is simply gone. lowest latency.stream — reliable, ordered within the streamclientserverdata →← ack; lost pieces re-sent until complete
Two ways to send over one WebTransport connection. A datagram is fire-and-forget: fast, but it may never arrive. A stream is acknowledged and re-sent until it does — reliable, at the cost of possible waiting.

Where WebTransport stands in mid-2026

This is the newest of the three transports, and its status genuinely changed recently, so be precise about it. WebTransport is a W3C specification. Chromium browsers (Chrome, Edge, Opera) have shipped it since 2021–2022, and Firefox since version 114. The last gap was Safari — and it closed in Safari 26.4, released March 2026. With that, WebTransport became Baseline “newly available” in 2026: supported across the current versions of every major browser.

Peer-to-peer or client-server?

The deepest difference between these two isn’t the API — it’s the shape of the network they build. WebRTC connects peers to each other. WebTransport connects clients to your server. That choice ripples into latency, cost, and how many participants you can support.

WebRTC — peer-to-peer meshP1P2P3P4no server in the media path — but links grow ~n²WebTransport — hub & spokeserverC1C2C3C4server sees everything — easy to authorize, log, scale out
WebRTC forms direct links between peers (a full mesh gets expensive fast). WebTransport keeps the classic hub-and-spoke: every client talks to the server, which fans out.

That mesh is why large WebRTC calls don’t actually stay peer-to-peer. Past a handful of participants, every peer sending to every other peer saturates upstream bandwidth, so production video platforms route through a media server (an SFU) that each peer connects to once — trading the pure-P2P latency win for something that scales. For a two-person call, direct P2P is ideal.

A rough decision rule: if the data must go between users, it’s WebRTC. If it goes between a user and you, start with WebSocket and step up to WebTransport when its stream multiplexing or datagrams earn their keep.

Summary

  • WebRTC links two browsers directly. You provide the signalling channel to swap an offer, an answer, and ICE candidates; the browser’s ICE agent uses STUN to discover public addresses and TURN to relay when no direct path exists.
  • RTCPeerConnection drives the connection; getUserMedia supplies camera/mic tracks via addTrack; incoming media arrives on the track event.
  • RTCDataChannel is a P2P pipe for arbitrary bytes with tunable reliability — set ordered plus either maxRetransmits or maxPacketLifeTime (never both) to trade guaranteed delivery for lower latency.
  • WebTransport is a client-to-server API over HTTP/3 / QUIC. It offers reliable, independently-multiplexed streams (no head-of-line blocking) and unreliable datagrams, all in terms of Uint8Array.
  • WebTransport became Baseline “newly available” in 2026 once Safari 26.4 shipped in March 2026 — current browsers support it, but older clients don’t, so feature-detect and keep a fallback.
  • Match the transport to the network shape: P2P and live media → WebRTC, low-latency client↔server → WebTransport, simple reliable bidirectional → WebSocket.