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.

WebTransport delivery primitives Datagrams are unreliable, unordered, limited to about 1.1 kilobytes and never block; one long stream is reliable and ordered but a loss blocks all its messages; a stream per message or per topic limits blocking to that message or topic. WebTransport delivery primitives Reliable Ordered Size Blocks others on loss Datagram no no ≈ 1.1 KB never One long stream yes yes unlimited all its messages Stream per message yes within message unlimited only that message Stream per topic yes per topic unlimited only that topic Stream-per-topic reproduces per-channel ordering without cross-channel blocking
Pick the primitive by what a lost packet should do to everything else.

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.

Illustrative p99 added delay for chat messages With no loss none of the layouts add delay; at one and three percent loss a single shared stream adds 120 and 310 milliseconds to chat at the 99th percentile, a per-topic stream adds 35 and 90, and datagram-carried state never adds delay to chat. Illustrative p99 added delay for chat messages chat sharing a connection with 20/s state updates one shared stream datagrams stream per topic 0 ms 100 ms 200 ms 300 ms 400 ms 0% loss 1% loss 3% loss
Separating lanes keeps state traffic's losses from stalling chat.

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);
Three lanes on one connection A client sends a cursor datagram, a chat message on a per-room stream and a file chunk on its own unidirectional stream; the cursor datagram is lost without affecting the chat or file chunk, and the next cursor tick supersedes it. Three lanes on one connection Client QUIC connection Server datagram: cursor tick 881 topic stream room:7: chat uni stream: file chunk 12 (FIN) packet of tick 881 lost chat + chunk unaffected datagram: tick 882 supersedes Loss is contained to the lane it happened in
Lanes turn one connection into several independent delivery channels.

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.

Back to WebTransport & HTTP/3.