WebTransport datagrams vs streams #
You have adopted WebTransport and now face a choice a WebSocket never asked of you: for each kind of message, should it travel as a datagram, on a unidirectional stream, or on a bidirectional stream — and should that be one long-lived stream or a new stream per message? Put everything on one bidirectional stream and you have rebuilt a WebSocket, head-of-line blocking included. Put everything in datagrams and chat messages silently vanish on lossy networks. The benefit of WebTransport comes entirely from matching delivery semantics to each message type, and the rules for doing so are straightforward once the properties of each primitive are laid out.
Root cause #
WebTransport exposes QUIC’s primitives almost directly, and they have very different guarantees.
Datagrams are unreliable and unordered: each is sent once, may be lost, may arrive out of order, and must fit in a single QUIC packet — roughly 1,100–1,200 bytes of payload in practice, available as datagrams.maxDatagramSize. They are never retransmitted, so they never stall anything.
Streams are reliable and ordered within the stream. Lost packets are retransmitted, and a loss on one stream delays only that stream. Streams can be unidirectional (one sender, like a message or file) or bidirectional (request/response or a long-lived channel). Opening a stream is cheap — no handshake, just a new stream id — so a stream per message is a legitimate pattern.
The design mistake is choosing by habit. Data that becomes obsolete quickly — positions, cursors, sensor samples, video metadata — wants datagrams, because retransmitting it wastes bandwidth and delays newer values. Data that must arrive — chat, commands, state changes — wants streams. Independent messages that must arrive but need not be ordered relative to each other want separate streams, so one loss does not delay the rest.
Resolution #
Map each message type explicitly. A typical real-time application ends up with three lanes: datagrams for high-rate superseding state, one stream per ordered topic (a chat room, a document), and short-lived streams for independent reliable messages and bulk transfers.
type Lane = 'datagram' | 'topic-stream' | 'message-stream';
// Explicit mapping, reviewed like an API: no message type picks its lane by accident.
const LANES: Record<string, Lane> = {
'cursor.move': 'datagram', // superseded 20×/s — never retransmit
'player.state': 'datagram',
'chat.message': 'topic-stream', // ordered within a room
'doc.update': 'topic-stream', // ordered within a document
'file.chunk': 'message-stream', // reliable, independent, large
'command': 'message-stream', // must arrive; order across commands irrelevant
};
export class LaneSender {
private dgWriter: WritableStreamDefaultWriter<Uint8Array>;
private topicWriters = new Map<string, Promise<WritableStreamDefaultWriter<Uint8Array>>>();
private enc = new TextEncoder();
constructor(private wt: WebTransport) {
this.dgWriter = wt.datagrams.writable.getWriter();
}
async send(type: string, topic: string, payload: object) {
const bytes = this.enc.encode(JSON.stringify({ type, topic, payload }) + '\n');
switch (LANES[type] ?? 'message-stream') {
case 'datagram': {
if (bytes.byteLength > (this.wt.datagrams.maxDatagramSize ?? 1_100)) {
throw new Error(`${type} too large for a datagram`); // redesign, don't fragment
}
void this.dgWriter.write(bytes); // fire and forget
return;
}
case 'topic-stream': {
const w = await this.topicWriter(topic);
await w.ready; // backpressure per topic
await w.write(bytes);
return;
}
case 'message-stream': {
const s = await this.wt.createUnidirectionalStream(); // cheap: no handshake
const w = s.getWriter();
await w.write(bytes);
await w.close(); // FIN marks message end
return;
}
}
}
// One long-lived unidirectional stream per topic keeps that topic ordered.
private topicWriter(topic: string) {
let p = this.topicWriters.get(topic);
if (!p) {
p = this.wt.createUnidirectionalStream().then((s) => s.getWriter());
this.topicWriters.set(topic, p);
}
return p;
}
}
The receiving side mirrors the lanes: read datagrams and discard stale ones by sequence number; for each incoming unidirectional stream, read until the end and dispatch the message; for topic streams, read newline-delimited messages in order. Stream-per-message gives you message boundaries for free — the stream’s end is the message’s end — which removes the framing work you would otherwise do on a long stream.
Backpressure is per lane. writer.ready on a stream resolves when QUIC’s flow control allows more data, so a slow topic slows only its own writer. Datagrams have no backpressure: if you write faster than the congestion controller allows, the browser queues briefly and then drops. Rate-limit datagram producers yourself, the same way you would coalesce updates in coalescing high-frequency WebSocket updates.
Edge cases #
Datagram size. The usable datagram size depends on the path MTU and QUIC overhead and can change during a connection. Design datagram payloads to be well under 1,000 bytes; anything that might exceed it belongs on a stream. Never build your own fragmentation over datagrams — a lost fragment loses the whole message, which is worse than a stream.
Stream limits. Servers advertise how many concurrent streams a client may open. A stream-per-message design that opens thousands of streams per second can hit that limit, and createUnidirectionalStream() will wait. Reuse topic streams for high-volume ordered traffic, and keep per-message streams for independent or large items.
Ordering across lanes. Nothing orders a datagram relative to a stream message. If a chat message refers to a position, include the tick it refers to in the chat payload rather than relying on arrival order.
Verification #
Run the lanes under emulated loss (tc netem loss 2%) and log per-lane delivery: datagram loss rate and staleness drops, per-topic latency percentiles, and per-message stream completion times. Confirm three things: chat latency does not spike when state traffic loses packets, datagram payloads never exceed the reported maxDatagramSize, and no lane’s queue grows without bound under sustained load.
// Receiver-side datagram stats: loss and reordering from sequence gaps.
let expected = 0, lost = 0, reordered = 0;
function onDatagramSeq(seq: number) {
if (seq > expected) lost += seq - expected;
else if (seq < expected) reordered += 1;
expected = Math.max(expected, seq + 1);
}
setInterval(() => console.info({ lost, reordered }), 10_000);
Operational checklist #
FAQ #
When should I use WebTransport datagrams? #
For small, frequent messages that a newer message makes obsolete — positions, cursors, sensor readings, telemetry samples. If losing one would leave the receiver wrong until something else is sent, use a stream instead.
How large can a WebTransport datagram be? #
It must fit in one QUIC packet, typically a little over 1,000 bytes of payload. Check transport.datagrams.maxDatagramSize at runtime and keep payloads comfortably below it.
Is opening a stream per message expensive? #
No — a QUIC stream needs no handshake, just a new stream id, so per-message streams are cheap. The limits are the server’s concurrent stream allowance and per-stream bookkeeping at very high rates.
Can I get ordered but unreliable delivery? #
Not directly. Use datagrams with a sequence number and drop anything older than the last applied value; that gives “latest wins” semantics, which is what ordered-unreliable use cases usually want.
Related #
- WebTransport vs WebSocket — whether to adopt it at all.
- QUIC Connection Migration for Real-Time Apps — what happens to lanes on a network change.
- WebRTC Data Channel vs WebSocket for Game State — unreliable channels in WebRTC.
- Ordering WebSocket Messages with Sequence Numbers — sequencing within and across lanes.
Back to WebTransport & HTTP/3.