For fifteen years, a browser that needed a two-way, low-latency channel to a server had one mainstream choice: WebSocket. WebSocket gives you one ordered, reliable byte pipe over TCP. That is exactly right for chat, and exactly wrong for a game, a live video feed or a collaborative editor under packet loss. A single lost packet stalls everything behind it, even messages that have nothing to do with the lost one and even data that is already stale by the time it arrives.
WebTransport is the web platform's answer. It gives a page a session to a server over HTTP/3 and QUIC. Inside that session the page can open many independent reliable streams and also send unreliable datagrams. As of March 2026 it runs in all major engines: MDN's compatibility data lists Chrome 97, Firefox 114 and Safari 26.4. This article explains the layers underneath it, the API on top, how backpressure works, how to design an application around streams and datagrams, and when WebSocket or WebRTC is still the better choice. The general ideas of stream multiplexing and backpressure have their own articles; this one is specific to WebTransport.
The problem WebTransport solves
TCP delivers one ordered byte stream. If segment 7 is lost, segments 8 to 40 wait in the receiver's kernel until 7 is retransmitted, typically one round trip or more later. This is head-of-line blocking. Put a game's position updates, a chat message and a file upload on one WebSocket, and a single loss delays all three. It also means a position update from 150 ms ago is still delivered, in order, after the retransmission, even though the application would rather have skipped it.
QUIC, the transport under HTTP/3, runs over UDP and makes streams a transport-level concept. Each stream has its own ordering and retransmission state, so loss on one stream delays only that stream. QUIC also supports unreliable datagrams, which are never retransmitted. WebTransport exposes both to web pages. Before it, unreliable delivery from a browser meant a WebRTC data channel, with signalling and ICE just to reach your own server.
The layers: QUIC, HTTP/3, and the session
A WebTransport session is not a new protocol on its own port. It is an HTTP/3 request. The browser opens, or with allowPooling reuses, a QUIC connection to the origin. Over that connection it sends an extended CONNECT request, the mechanism HTTP/2 and HTTP/3 use to turn a request stream into a tunnel. If the server answers 2xx, the session exists. Its identity is the stream id of that CONNECT request stream, which lives as long as the session does.
The wire protocol is still an IETF draft, and it has changed its identifiers across revisions, so implementations negotiate a draft version. In draft-ietf-webtrans-http3-16 (July 2026) the CONNECT request uses the :protocol value webtransport-h3. The server advertises SETTINGS_WT_ENABLED, SETTINGS_ENABLE_CONNECT_PROTOCOL and SETTINGS_H3_DATAGRAM, and both sides negotiate QUIC datagram support. A new bidirectional stream starts with the signal value 0x41 followed by the session id. A unidirectional stream uses stream type 0x54. Datagrams are HTTP datagrams whose payload follows the Quarter Stream ID of the session. Earlier drafts used different setting names and a different protocol token, so do not hand-roll this layer. Use a maintained server library and let it track the draft your target browsers speak.
Because the session is an HTTP request, it carries an Origin header the server must check, can carry cookies or tokens, and can be refused with an ordinary status code. A WebTransport-over-HTTP/2 draft exists for networks that block UDP, without real datagrams; requireUnreliable lets a page refuse it.
Three primitives and what each is for
| Primitive | Delivery | Opened by | Good for |
|---|---|---|---|
| Bidirectional stream | Reliable, ordered within the stream, independent of other streams | Either side | Request/response pairs, RPC calls, a chat channel, a file transfer with acknowledgement |
| Unidirectional stream | Reliable, ordered, one direction only | Either side | Server push of a snapshot, a log tail, one message per stream |
| Datagram | Unreliable, unordered, no retransmission, size-capped | Either side | State that is superseded quickly: positions, sensor readings, media frames, pings |
The biggest design decision is how to map messages onto streams. A common and robust pattern is one stream per message or per request. Opening a QUIC stream needs no handshake, loss affects only the message it hits, and the stream's end marks the message boundary. One long-lived stream for many messages brings back head-of-line blocking and manual framing; use it only when messages must stay ordered, as with edits to one document.
Datagrams must fit in one QUIC packet, so transport.datagrams.maxDatagramSize is typically somewhat over a thousand bytes, depending on the path. Anything larger has to be split by the application or sent on a stream. Datagrams can also be dropped by the sender's own queue if the congestion controller will not let them out, and that is by design: stale real-time data is worth less than fresh.
The browser API
The API is built on WHATWG streams and promises. Readers use getReader() loops, because Safari 26.4 lacks async iteration of streams:
const wt = new WebTransport("https://game.example.com:4433/session", {
requireUnreliable: true, // refuse a fallback without real datagrams
congestionControl: "low-latency", // a hint; the browser may ignore it
});
wt.closed
.then(({ closeCode, reason }) => console.log("closed", closeCode, reason))
.catch((err) => console.error("session failed", err));
await wt.ready; // rejects if the CONNECT fails
// 1. A request/response exchange on its own bidirectional stream.
async function call(obj) {
const stream = await wt.createBidirectionalStream();
const writer = stream.writable.getWriter();
await writer.write(new TextEncoder().encode(JSON.stringify(obj)));
await writer.close(); // FIN marks the end of the request
const reply = await new Response(stream.readable).arrayBuffer();
return JSON.parse(new TextDecoder().decode(reply));
}
// 2. Streams the server opens towards us.
(async () => {
const streams = wt.incomingUnidirectionalStreams.getReader();
for (;;) {
const { value: recv, done } = await streams.read();
if (done) break;
handleSnapshot(await new Response(recv).arrayBuffer());
}
})();
// 3. Datagrams. createWritable() is the current spec; older engines expose .writable.
const dgw = (typeof wt.datagrams.createWritable === "function"
? wt.datagrams.createWritable() : wt.datagrams.writable).getWriter();
(async () => {
const dgr = wt.datagrams.readable.getReader();
for (;;) {
const { value, done } = await dgr.read(); // value is a Uint8Array
if (done) break;
applyState(value);
}
})();
function sendInput(bytes) {
if (dgw.desiredSize <= 0) return; // queue full: drop, a newer input follows soon
dgw.write(bytes);
}
// Orderly shutdown with an application code and a short reason.
wt.close({ closeCode: 4000, reason: "leaving match" });Some parts of the specification are newer than some implementations. The W3C Candidate Recommendation defines createWritable() for datagrams, send groups, a sendOrder option for prioritising streams, getStats() and a draining promise. MDN still documents a deprecated writable attribute and high-water-mark properties that the latest specification renames. Feature-detect everything beyond the core shown above, and treat prioritisation as a hint that may be ignored.
Flow control and backpressure
There are three layers of limits, and an application that ignores them either runs out of memory or sees mysterious stalls.
- QUIC flow control. Each stream and the connection as a whole have credit limits that the receiver raises as it reads. A peer that stops reading stops granting credit, and the sender blocks. WebTransport also has session-level limits for the number of streams and the bytes in flight (in the draft,
SETTINGS_WT_INITIAL_MAX_DATA,WT_MAX_DATAand related capsules), so one session cannot starve others that share a pooled connection. - Streams API backpressure. A
WritableStreamDefaultWriterexposesreadyanddesiredSize. Awaitwriter.readybefore each write on a reliable stream. If you just callwrite()in a loop, the browser buffers everything you give it. - Datagram queues. Outgoing datagrams wait in a bounded queue, and those older than the outgoing maximum age are discarded. On the receiving side, if your code does not read
datagrams.readablefast enough, incoming datagrams are dropped. CheckdesiredSizeand drop at the application level, choosing which data to lose, rather than letting the queue decide.
So each class of data needs a slow-peer policy decided up front: block, drop the oldest, or disconnect.
Worked example: a real-time match
Take a browser game with 16 players, a server tick of 30 Hz, and three kinds of traffic: player inputs, world state and chat. Here is how each maps onto WebTransport:
- Inputs, client to server: datagrams. Each datagram carries a sequence number and the last few inputs, a redundancy trick that survives a lost packet without retransmission. About 40 bytes, 60 times per second, is 2.4 KB/s per player.
- World state, server to client: datagrams. Delta-compressed snapshots against the last snapshot the client acknowledged. With 16 players at about 24 bytes of position and velocity each, plus a 16-byte header, a snapshot is about 400 bytes, well under one datagram. At 30 Hz that is 12 KB/s down per player. If one is lost, the next one supersedes it.
- Match events, server to client: one unidirectional stream per event. A kill, a score change or the end of a round must arrive, but events need not be ordered relative to one another.
- Chat and lobby RPCs: bidirectional streams. One stream per request, so a slow lobby query never delays a chat message.
The full world state on join exceeds maxDatagramSize, so it goes on a reliable stream rather than as fragments, where one lost piece wastes the rest. The client keeps only the newest snapshot and interpolates; sequence numbers let the server drop stale inputs. Ordering across streams is covered in bidi message ordering.
The server side
Server libraries differ in API, so the handler below is pseudocode. Every implementation has the same shape: accept the CONNECT, check it, then serve streams and datagrams concurrently.
on_connect(request):
if request.origin not in ALLOWED_ORIGINS: return respond(403)
user = authenticate(request.headers) # token in a header or cookie
if not user: return respond(401)
session = accept(request) # 2xx: session established
spawn(read_datagrams(session, user))
spawn(accept_bidi_streams(session, user))
every tick: session.send_datagram(snapshot_for(user)) unless queue_full
accept_bidi_streams(session, user):
for stream in session.incoming_bidi():
spawn(handle_rpc(stream, user)) # one task per stream, isolated failures
handle_rpc(stream, user):
req = read_until_fin(stream, limit=64KB) # bound every read
stream.write(dispatch(user, req)); stream.finish()
on error: stream.reset(app_error_code) # affects this stream onlyThe server needs its UDP port open, and a load balancer must route QUIC by connection ID rather than by client address, because connections can migrate (see QUIC connection migration). The URL must use https:.
Development certificates and serverCertificateHashes
WebTransport requires TLS. For local development, or for game servers on raw IP addresses without a public certificate, the page can pin a certificate by hash instead of using the normal certificate authority chain. Pass serverCertificateHashes with entries of the form {algorithm: "sha-256", value: <32-byte digest>}. The rules are strict. The certificate must be an X.509v3 certificate valid for less than two weeks, and the current time must fall within that window. It must not use an RSA key; ECDSA P-256 is the key type every implementation must accept. Pinning also requires a dedicated connection, so combining it with allowPooling: true throws a TypeError. In practice the server rotates a short-lived certificate and the page fetches the current hash over an authenticated HTTPS endpoint before connecting.
Failure modes
- UDP blocked. Some corporate and hotel networks drop UDP 443.
readyrejects or hangs until timeout. Keep a WebSocket fallback for reliable traffic and degrade real-time features gracefully. - Session close resets everything. When the session ends, every stream in it is reset with a session-gone error. Code that awaits a stream read should expect a
WebTransportErrorwithsourceset to"session", and reconnect logic must rebuild application state, not just the transport. - Unbounded writes. Writing without awaiting
writer.readybuffers without limit on a slow link and ends in a tab crash or a huge latency spike. - Oversized datagrams. Writes larger than
maxDatagramSizefail. Check the size on each path; do not hard-code it. - Idle timeouts. Idle QUIC connections close, so quiet sessions need a heartbeat well inside the timeout.
- Draft skew. A server library that only speaks a newer or older draft than a browser fails the handshake. Pin library versions and test every target browser in CI.
Trade-offs: WebTransport, WebSocket, WebRTC, SSE
| Need | Best fit | Why |
|---|---|---|
| Server-to-client events over plain HTTP | SSE | Simplest, works through every proxy, automatic reconnection. |
| Reliable bidirectional messages, maximum reach | WebSocket | TCP works on every network; mature proxies, load balancers and libraries. |
| Many independent reliable flows, plus unreliable real-time data, client to server | WebTransport | No head-of-line blocking between streams; datagrams without WebRTC's setup. |
| Peer-to-peer audio and video, NAT traversal | WebRTC | Media pipeline, jitter buffers and ICE are built in; WebTransport is client to server only. |
WebTransport needs UDP reachability, QUIC-aware load balancing and younger server libraries, and user-space QUIC often costs more CPU per byte than kernel TCP. Choose it when head-of-line blocking or missing datagrams is a measured problem.
What to do next
- Measure first: record p99 message latency on your WebSocket under 1 to 2 percent packet loss (for example with
tc netem), and decide whether head-of-line blocking is actually hurting you. - Classify your traffic into must-arrive-ordered, must-arrive-independent and newest-wins, and map each class to one long stream, one stream per message, or datagrams.
- Stand up a server with a maintained HTTP/3 WebTransport library, check that UDP is reachable end to end, and confirm your load balancer routes QUIC by connection ID.
- Build the client with feature detection for
createWritable,sendOrderandgetStats, awaitwriter.readyon every reliable write, and checkdesiredSizebefore datagram writes. - Keep a WebSocket fallback for networks without UDP, and test it by blocking UDP 443 on a test machine.
- Add metrics for session establishment failures, stream resets by code, datagram send and receive counts and queue drops, then load test before launch.