Skip to content

transport-io wire protocol

Protocol version: 0. Unstable. During Stage 0 both peers require exact version equality and refuse the session otherwise. The negotiation mechanism described in §4 exists; the compatibility promise does not.

This document is the specification. It is written so that an implementer with no access to the reference source - someone writing a Go server, for instance - can build an interoperable peer from this document alone. Where the reference implementation and this document disagree, this document is correct and the implementation is a bug.


1. Conventions

All multi-byte integers are unsigned big-endian (network byte order).

Field widths are written as (N) meaning N bits. u8, u16, u32 mean unsigned integers of 8, 16 and 32 bits.

“MUST”, “MUST NOT”, “SHOULD” and “MAY” carry their usual specification force.

A protocol error means the receiver MUST NOT process the offending data. Whether it resets a stream or closes the session is specified per case in §10.


2. Transport requirements

transport-io runs over WebTransport on HTTP/3 (QUIC), which carries every lane. One other mapping exists, the emit lane over a WebSocket (§3.3), and a peer accepts it only for a contract in which every unreliable event declares what it accepts there (§7.6). Nothing else carries a session.

A peer MUST NOT establish or accept a session over WebTransport on HTTP/2. That mapping retransmits lost data, which would make the unreliable lane (§7) reliable and ordered while the contract continues to advertise it as unreliable. Silently changing a guarantee the application declared is the specific failure this protocol exists to prevent.

Servers MUST refuse HTTP/2 WebTransport sessions rather than downgrading. This is the enforcement point: a server that never offers the HTTP/2 mapping cannot be negotiated into it, regardless of client behaviour. Clients SHOULD additionally request unreliable-capable sessions where the platform exposes that control, and MUST refuse a WebTransport session it can observe to be reliable-only.

A session on a transport that carries only the reliable lane MUST be refused with WT_RELIABILITY_REFUSED (§10.2), before the handshake, unless every unreliable event in the peer’s contract declares a fallback (§7.6). Each peer knows the transport from the listener or connector that produced the connection; the handshake never carries it.


3. Streams and their roles

On WebTransport, a session uses two kinds of QUIC stream, plus datagrams. §3.3 is the one other mapping, and it carries the first row of the table below and nothing else.

Lane names describe the guarantee; this section describes the mechanism that carries it. The reliable lane is carried on QUIC streams. The unreliable lane is carried on QUIC datagrams. A contract says lane: 'reliable' or lane: 'unreliable' because that is the property an application depends on; everything below is how it is delivered.

purposestream kindlifetime
Emit, reliable laneone unidirectional stream per directionwhole session
Call, reliable laneone bidirectional stream per callone request/response exchange
Unreliable laneQUIC datagrams, not a stream-

3.1 The emit lane

Each peer opens exactly one unidirectional stream for the whole session and writes all of its stream-lane traffic to it, framed per §5. The client’s emit stream carries client-to-server traffic; the server’s carries server-to-client traffic.

Frame 0 of the emit stream is always the handshake (§4). Because QUIC guarantees in-order delivery within a stream, no other stream-lane frame can be observed before the handshake. This removes the early-traffic race by construction rather than guarding against it.

Consequence, stated plainly: head-of-line blocking on the emit lane is cross-room. All rooms share one emit stream per direction, so a high-volume room delays a quiet room’s messages to the same peer. Calls and datagrams are unaffected - they use separate streams and separate packets - but emits to one peer are serialised across every room that peer belongs to. Implementers should not describe the emit lane as offering per-room independence.

A per-room emit lane is reserved as the emit-per-room feature token (§4.2) and is not part of version 0.

3.2 Call streams

Each call opens a new bidirectional stream. There are no correlation identifiers, because the stream is the correlation. A stalled call therefore cannot block another call: QUIC flow control applies per stream.

The response is a sequence of frames terminated by stream close, not a single frame with a length. How long that sequence may be, and whether the initiator keeps its send side open, is decided by the event’s contract entry and not by the call site:

the event declaresthe initiatorthe responder
returnsone CALL_REQUEST, then FINexactly one CALL_RESPONSE, or one CALL_ERROR, then close
yieldsone CALL_REQUEST, no FIN, then CALL_CREDIT frameszero or more CALL_RESPONSE, optionally one CALL_ERROR, then close

Both peers know which shape applies before any response frame arrives, because the event table is exchanged at handshake (§4.3). The shape cannot be chosen per call site. A responder that yields nothing closes the stream with zero CALL_RESPONSE frames, and those are the same bytes a broken returns responder produces. In the returns shape that is a protocol error; in the yields shape it is a complete empty sequence. The contract is what distinguishes them.

Receivers MUST accept a CALL_RESPONSE sequence of any length regardless of shape, so that a peer implementing only returns events is not broken by one that implements both.


3.3 The emit lane over WebSocket

The only mapping other than WebTransport, and it carries the emit lane alone. A WebSocket is one ordered, reliable pipe per direction, which is what §3.1 describes, so the socket is both peers’ emit streams: the client’s messages are the client’s emit stream and the server’s are the server’s. Frame 0 is the handshake (§4), unchanged.

ruleon this mapping
messagesbinary only; a text message is a protocol error
framing§5, unchanged: a message carries bytes of the stream, and message boundaries carry no meaning
bidirectional streamsnone; a call fails locally with WT_LANE_UNAVAILABLE (§10.3)
datagramsnone on the socket; a declared unreliable event travels as a DATAGRAM frame (§5.2) whose payload is a §7.1 datagram, unchanged
unreliable frames on the lanequeued in the §9 ring, and moved onto the emit lane only while it holds fewer than 32 frames
closeWT_NO_ERROR as WebSocket code 1000, every other §10.2 code as 3000 plus the code, and the reason cut to 123 bytes on a character boundary
keepalivean empty binary message, sent by a peer that has sent nothing for 15000 ms; it carries no bytes of the stream and counts as a message received
idle deadlinea peer that has received nothing for 45000 ms closes the session as WT_IDLE_TIMEOUT (§10.2)

A peer MUST send binary messages only and MUST treat a text message as a protocol error.

A peer MUST NOT open a bidirectional stream on this mapping. A DATAGRAM frame MUST be refused as a protocol error on any other mapping, where it would carry an unreliable event on a lane that acknowledges.

A DATAGRAM frame’s header carries codec 0x01 and event id 0; the datagram inside it carries the real event id, origin and sequence, and the receiver applies §7.3 to it as it would to a datagram. A sender MUST queue unreliable frames in the §9 datagram ring and MUST move them onto the emit lane only while that lane is below the mark in the table, so a burst on the unreliable lane cannot close the session as WT_PEER_TOO_SLOW.

Session close codes MUST be carried as the table says. A peer that closes treats the session as closed at once rather than waiting for the closing handshake, which a peer that has stopped reading may never complete. A WebSocket close code outside the table is the socket’s own, 1006 for a lost connection or 1001 for a page going away, and is not a session close code: the numbers overlap §10.2, where 1006 is WT_RELIABILITY_REFUSED. The session ended with no code (§11).

TCP reports a dead path late or never, so the mapping carries its own liveness. A peer MUST send an empty binary message once the keepalive interval has passed with nothing sent, and MUST treat an empty message it receives as carrying no bytes of the stream. A peer MUST close the session as WT_IDLE_TIMEOUT once the idle deadline has passed with nothing received, a keepalive counting as something received.


4. Handshake

A server that refuses a peer at the door MUST close the session as WT_UNAUTHORIZED before sending its frame 0, so a refused peer receives the reason and nothing else, the event table included.

The close reason of a refusal is the application’s reason, whole: a short code the client compares, expired, and never prose. It MUST be 1 to 123 bytes of UTF-8, which is the WebSocket mapping’s cap on a close reason (§3.3), so a reason arrives the same on either mapping and is never cut. A refusal with nothing to say uses refused.

WT_UNAUTHORIZED is an answer about the request, and a client treats it as final: the same request would be refused again, so a client that reconnects on its own MUST stop on it. A server that fails to reach a decision, its verification backend down, MUST NOT close as WT_UNAUTHORIZED, because nothing was decided and the next attempt may be. The reference server closes such a session as WT_NO_ERROR with the reason authorize failed. A server MAY close an established session as WT_UNAUTHORIZED, a credential that expired, with the same meaning.

A client opening its emit stream on a refused session may see that stream fail before the session’s close code is delivered; Chromium does. A client SHOULD wait briefly for the close before reporting a failed handshake, or it reports a refusal as a lost session.

4.1 Frame

Each peer writes exactly one HANDSHAKE frame as frame 0 of its emit stream, immediately on session establishment. The payload is a JSON object:

{ "v": 0, "feat": [], "events": [["chat", 836792189, "reliable"]] }
fieldtypemeaning
vintegerProtocol major version.
featarray of stringFeature tokens this peer supports. May be empty.
eventsarrayEvent table, §4.3.

A peer MUST send its handshake without waiting for the other side’s.

Deadline: 5000 ms. If a peer has not received a valid handshake frame within 5000 ms of session establishment, it MUST close the session with WT_HANDSHAKE_TIMEOUT (§10.2).

A peer that never opens its emit stream is indistinguishable from one that opens it and never writes, so the same deadline covers both. This matters in practice: some clients establish a WebTransport session successfully and then never transmit application bytes. Without the deadline that state is a silent hang.

4.2 Version and feature negotiation

v mismatch: the session is refused with WT_PROTOCOL_VERSION_MISMATCH (§10.2).

During Stage 0 the comparison is exact equality. From protocol version 1 onward, a major mismatch refuses the session and the active feature set is the intersection of the two feat arrays, so older peers keep working and newer ones enable extras.

Feature tokens are short lowercase ASCII. Reserved and unimplemented in version 0:

tokenmeaning
emit-per-roomSeparate emit stream per room (§3.1).
codec-msgpackMessagePack codec, codec id to be assigned (§5.3).
session-resumeResumption of a prior session’s identity and membership.

An unrecognised token MUST be ignored, not treated as an error.

4.3 Contract identity

Peers exchange their event table and compare it per event. There is no whole-contract hash, because an all-or-nothing comparison refuses a session over differences that do not matter. See ADR 0011.

The events field of the handshake is an array of [name, id, lane] triples, sorted ascending by name by Unicode code point:

{ "v": 0, "feat": [],
"events": [["chat", 836792189, "reliable"], ["cursor", 1185214141, "unreliable"], ["save", 360565394, "reliable"]] }

Each peer compares the two tables entry by entry:

conditionoutcome
Same name, different laneRefuse, WT_CONTRACT_MISMATCH. The peers disagree about a delivery guarantee the application depends on.
Same name, different idRefuse, WT_CONTRACT_MISMATCH. A genuine decoding disagreement.
Same id, different nameRefuse, WT_CONTRACT_MISMATCH. Collision override disagreement.
Name known to one peer onlyProceed. Sending it yields a per-message WT_UNKNOWN_EVENT.

A refusal MUST name the offending event in the close reason, for example event 'cursor' is 'unreliable' here and 'reliable' at the peer.

Property worth knowing before you deploy: the server sends its event table to every peer its listener accepts. Anyone who reaches a handshake learns the full set of event names and lanes - not payloads, not schemas, not data, but the surface. For almost every application this is uninteresting, and it is the same information a client bundle already contains. It matters when event names encode unreleased features or internal structure.

The door is the listener’s authorize, which decides each peer from the request that opened the session, its path, query and peer address, before the session is accepted. A refused peer is closed as WT_UNAUTHORIZED (§10.2) before this side’s frame 0, so it receives the reason (§4) and never the table. The handshake payload itself is exhaustively { v, feat, events } and carries no credential: a browser can put a token only in the request’s query string, and nothing can stand in front of the QUIC endpoint, since a proxy drops UDP. Without an authorize, every peer that opens a session is accepted and receives the table before any application code runs.

Payload schema shape is not exchanged and not compared. A schema disagreement produces one WT_VALIDATION_FAILED on one message, which is local, readable and recoverable. An identity disagreement corrupts every message of that type, silently. The handshake refuses what cannot be caught later and permits what can - so adding an optional field to a payload is not a breaking change, and adding or removing an event is safe during a rolling deploy.

4.4 Ordering of the two checks

The event table is validated first, and conflicts are fatal. feat is negotiated second, and is never fatal. They are independent axes: no feature token rescues a lane disagreement, and no agreement about lanes implies a shared feature set.

5. Frame layout on streams

Every stream-lane and call-stream frame uses this layout. QUIC streams are byte streams and do not preserve write boundaries - a single write may be delivered as many reads, and many writes may be delivered as one - so the length prefix is the only way to recover frame boundaries.

0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Type (8) | Codec (8) | Reserved (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Event ID (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Payload (*) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

5.1 Field budget

fieldbytesnotes
Length4Counts every byte after this field: 8 header bytes plus payload.
Type1§5.2
Codec1§5.3
Reserved2MUST be zero. Rejected as a protocol error otherwise.
Event ID4§5.4
Fixed overhead124 length + 8 header
Payload1 to 1 048 5761 to 16 777 216 on the call frames; see the caps below

Length MUST be at least 9 - eight header bytes plus at least one payload byte.

A Length of 0 is a protocol error. So is a payload of zero bytes. Stream close is the terminator for a call response (§3.2), so no zero-length sentinel is needed anywhere in this protocol, and permitting one is actively harmful: at least one widely used QUIC stack halts on a zero-length application write. Receivers MUST reject such a frame rather than forward it.

On every frame type except the call frames, Length exceeding 1048584 (1 MiB payload plus the 8 header bytes it counts) is a protocol error. Length counts only bytes after itself, so the four bytes of the Length field are not included - the same convention its minimum of 9 already reflects. The call frames carry a larger cap, stated immediately below; read both before implementing either.

The 16 MiB cap applies to the call frames - CALL_REQUEST, CALL_RESPONSE and CALL_ERROR - because a call is the documented home for payloads too large to emit and inheriting the emit cap would leave them nowhere to go. Every other frame type is capped at 1 MiB, including EMIT and the control frames, which are orders of magnitude smaller in practice.

A receiver MUST decide the cap from the frame type rather than applying the largest one universally. The type byte is at a fixed offset inside the header, so it is readable before any payload has to be buffered; applying the call cap to an EMIT frame lets a peer make a receiver hold sixteen times what this section permits. Exceeding either cap is a protocol error raised by the decoder as WT_PAYLOAD_TOO_LARGE, not a §10.1 reset code. On a call stream the receiver abandons the stream, and the initiator’s call rejects with WT_PROTOCOL_ERROR because no response frame arrived. On the emit stream, §5.5 applies.

5.5 Errors on the emit stream escalate

There is exactly one emit stream per direction and no way to reopen it, so resetting it would destroy all stream-lane traffic for the rest of the session with no recovery path.

A protocol error detected on the emit stream therefore closes the session with the corresponding code from §10.2. It never resets the stream. This is the one place where a frame-level fault is deliberately escalated to a session-level one, and the reason is structural rather than cautious: stream-level recovery is meaningless when the stream is the lane.

5.2 Type

valuenamevalid on
0x00reserved, invalid-
0x01HANDSHAKEemit stream, frame 0 only
0x02EMITemit stream
0x03CALL_REQUESTcall stream, first frame only
0x04CALL_RESPONSEcall stream
0x05CALL_ERRORcall stream, terminal
0x06JOINemit stream, server to client only
0x07LEAVEemit stream, server to client only
0x08CALL_CREDITcall stream, initiator to responder, streaming events only
0x09DATAGRAMemit stream, WebSocket mapping only (§3.3)
0x0A0xFFreserved-

Receiving a reserved or contextually invalid type is a protocol error.

5.3 Codec

valuename
0x00reserved, invalid
0x01JSON, UTF-8 encoded
0x02bytes: the application’s payload as it is, with no encoding
0x030xFFreserved

Version 0 peers MUST send 0x01 or 0x02 and MUST reject any other value with WT_UNSUPPORTED_CODEC.

The codec is a property of the event’s slot in the contract, its payload, its returns or its yields, declared with bytes() for 0x02 and anything else for 0x01. It is not exchanged at handshake, because it is caught on every frame: a receiver MUST treat a frame whose codec is not the one its contract declares for that event and slot as a protocol error, naming the event and the slot in the close reason.

Control frames (HANDSHAKE, JOIN, LEAVE, CALL_ERROR, CALL_CREDIT) and the header of a DATAGRAM frame (§3.3) always carry 0x01; the datagram inside a DATAGRAM frame carries its own codec byte, as any datagram does (§7.1).

0x00 is permanently reserved as invalid so that a zero-filled buffer can never parse as a valid frame. This is deliberate and cheap corruption detection.

5.4 Event ID

The first four bytes of SHA-256 of the event’s name, big-endian, as a u32.

Identity is derived from the name, never from position. Two peers computing an ID for the same name therefore always agree, and adding, removing or reordering events changes no existing identifier - which is what makes a contract change survivable during a rolling deploy. See ADR 0010.

Two names in one contract whose hashes collide are a contract construction error, reported when the contract is built, naming both events. The fix is an explicit id on one of them, which becomes part of the contract and is therefore shared by both peers.

0x00000000 means not applicable and is used by HANDSHAKE, CALL_RESPONSE, CALL_ERROR, JOIN and LEAVE - every frame whose meaning comes from the stream or from its own payload rather than from the event table. A room name is not a contract event, so JOIN and LEAVE have no event identity to carry.

An Event ID that does not correspond to a contract entry is answered with WT_UNKNOWN_EVENT. This is a per-message error, not a session fault: peers running adjacent contract versions legitimately know different event sets (§4.3).


6. Message types

6.1 EMIT

Fire and forget, on the emit stream. Payload is the encoded event payload. There is no acknowledgement and no response.

6.2 CALL_REQUEST

The first frame the initiator writes on a call stream. Payload is the encoded request payload.

For an event declaring returns, it is also the last: the initiator sends FIN immediately, which is what tells the responder the request is complete. For an event declaring yields the initiator MUST NOT send FIN, because its send side carries CALL_CREDIT (§6.6) for the life of the stream. A responder that received a CALL_REQUEST for a yields event begins work on that frame alone and does not wait for FIN.

FIN from the initiator of a streaming call therefore means “no further credit is coming”. The responder MUST treat it as cancellation and stop producing, rather than stalling for ever once its window is spent.

6.3 CALL_RESPONSE

Written by the responder on the call stream, then stream close. Payload is the encoded response payload. Event ID is 0x00000000.

For an event declaring returns, a responder MUST write exactly one CALL_RESPONSE, or one CALL_ERROR, and never zero of both. A receiver MUST nonetheless accept a sequence of any length in this shape too, so that a peer is never broken by a sequence it did not expect.

For an event declaring yields, a responder writes zero or more CALL_RESPONSE frames, one per element, and terminates by closing its send side. Zero frames followed by close is a complete and empty sequence, not an error. Each element is exactly one frame: a responder MUST NOT coalesce two elements into one frame, because the receiver has no way to split them again.

6.4 CALL_ERROR

Written by the responder, then stream close. Terminal. Event ID is 0x00000000.

For a returns event it replaces the CALL_RESPONSE. For a yields event it MAY follow any number of CALL_RESPONSE frames: a producer that fails partway through has already delivered what came before, and a receiver MUST surface those elements to the application and then raise the error. Elements are not retracted, because they cannot be.

Payload is a JSON object:

{ "code": "WT_VALIDATION_FAILED", "message": "field 'body' must be a string" }
fieldtypemeaning
codestringA WT_-prefixed error code. §10 does not tabulate the set a CALL_ERROR may carry, and the example above uses one that appears in none of its three tables: a responder forwards whatever code the handler threw. Treat unknown codes as opaque.
messagestringHuman-readable, stating what to do about it.

6.6 CALL_CREDIT

Written by the initiator of a streaming call, on the call stream, at any point after the CALL_REQUEST. Event ID is 0x00000000. Payload is a JSON object:

{ "credit": 16 }
fieldtypemeaning
creditnumberAdditional CALL_RESPONSE frames the responder may send. Positive integer.

A responder begins with 32 frames of credit and spends one per CALL_RESPONSE written. A responder MUST NOT write a CALL_RESPONSE while its credit is zero; it waits instead. An initiator MUST grant credit for elements the application has consumed, in batches of 16. Without that, a responder stops at 32 elements and never resumes.

A frame with a non-positive or non-numeric credit is ignored, not a protocol error.

An initiator’s refill batch MUST NOT exceed the initial window. A responder stops at zero having sent fewer elements than a larger batch requires before it is granted, and both sides then wait for ever. Measured, by setting a window of 4 against a batch of 16: it deadlocks.

A responder whose credit is zero waits. It MUST NOT drop elements, and MUST NOT impose a deadline of its own: a slow consumer is the case backpressure exists to serve, and waiting is what serving it looks like. An application that wants a deadline supplies one, which on this implementation is an AbortSignal passed to stream().

Nothing in this scheme distinguishes a consumer that is slow from one that is gone, and it is not meant to. That distinction belongs to session liveness, so an implementation MUST abort every response it is still serving when the session ends. Without it a parked responder outlives its own session, holding a stream slot and whatever its handler had open, with nothing left that could ever wake it.

Why this exists rather than relying on transport flow control. It was measured. On the reference binding a WritableStreamDefaultWriter’s ready resolves unconditionally, so a generator yielding as fast as it could ran 136,523 frames and roughly 53 MB ahead of a consumer that had taken 40, and the gap grew linearly with the run at every element size. An implementation that trusts the transport here is not applying backpressure, it is buffering without a bound. With the credit window the same measurement is 33 frames, flat. See D93.

This holds even over a transport whose own flow control is honest: the responder is entitled to stop at zero and has no way to know which transport it is talking to.

6.5 JOIN and LEAVE

Server to client only, on the server’s emit stream. Payload is a JSON object { "room": "lobby" }.

Rooms are server-authoritative. A client cannot join or leave by sending a frame; a client-sent JOIN or LEAVE is a protocol error. These frames exist so a client can maintain an accurate view of its own membership. An application that wants client-initiated subscription implements it as a call, which is already the authenticated path.


7. Unreliable lane

Carried on QUIC datagrams. Everything in this section is about datagrams as a transport mechanism; the lane is named for what it promises the application, which is nothing.

7.1 Layout

0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Codec (8) | Event ID (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ...Event ID | Origin (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ...Origin | Sequence (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ...Sequence | Payload (*) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

There is no length prefix: a datagram is a message, and its boundary is the datagram itself.

7.2 Field budget

fieldbytesnotes
Codec1Same table as §5.3.
Event ID4Same rules as §5.4. 0x00000000 is invalid here.
Origin4§7.3
Sequence4§7.3
Fixed overhead13
Payload1 to limit − 13§7.4

A zero-length payload is a protocol error, as on streams (§5.1).

A datagram shorter than the fixed overhead carries no event. A receiver MUST discard it silently, and the session stays open. An implementation MAY send a zero-length datagram as a liveness probe: a QUIC stack that gives up on a peer only when something it sent goes unacknowledged never gives up on a silent one, and an empty datagram is something sent. The reference server sends one every 15000 ms on each session.

7.3 Sequence

Origin identifies the peer that produced the datagram. It is stamped once by the session host that owns that peer and is never rewritten in transit.

Origin is allocated, not derived. A hash of the PeerId would carry the same birthday problem as a hashed event ID: roughly 0.01% at 1,000 concurrent peers and 1.2% at 10,000. A collision here is close to undebuggable from outside: two peers silently share a sequence space and each discards the other’s datagrams as stale. Allocation removes the class of failure instead of making it rare.

The normative requirements are:

  1. Who assigns it. The session host that accepts the peer, at session establishment, before any datagram is sent.

  2. Uniqueness scope. An origin MUST be unique among all peers concurrently connected to the same deployment - not merely to the same process. Uniqueness within one process is not uniqueness across a bus.

  3. Reuse, via quarantine. An origin MUST NOT be reissued while any peer that observed it may still hold sequence state for it. It MAY be reissued once that is impossible.

    Never reusing would turn the counter into a clock rather than a capacity limit. 2²² values at 100 sessions per second exhausts in 11.7 hours, and at 500 per second in 2.3 hours, after which a busy host stops accepting sessions until it is restarted. The failure depends on uptime multiplied by load, so it appears in production rather than in testing.

    Reuse is safe because both windows that could confuse a reused origin are bounded by values this protocol sets:

    • Receiver sequence-state retention. A receiver MUST discard its (origin, event) sequence state after 60 seconds with no datagram for that pair.
    • Maximum in-flight datagram lifetime. A datagram older than the send-queue TTL (§9, 150 ms) is never transmitted, so an in-flight datagram cannot outlive that TTL plus network transit.

    A released origin is therefore quarantined for at least 120 seconds - twice the longer of the two bounds - before returning to the pool. There is no mechanism by which a datagram or a sequence entry survives that interval.

    With quarantine, steady-state occupancy is concurrent + churn × 120s. At 500 sessions per second that is 60,000 values, 1.4% of the space; at 20,000 per second it is 2.4 million, 57%. Exhaustion becomes a genuine limit on concurrency, roughly 4.2 million live-plus-quarantined sessions per host, and never a function of how long the host has been up. A host that actually reaches it MUST refuse new sessions with WT_TOO_MANY_STREAMS-style clarity rather than wrap, because at that point the limit is real.

  4. Across processes. A single-process deployment MAY use a plain monotonic counter with the quarantine above. A deployment with more than one session host MUST partition the space so that two hosts cannot issue the same value. The recommended partition is a 10-bit host ordinal in the high bits and a 22-bit per-host counter in the low bits.

    Stated limit: 1,024 concurrent session hosts. This is a real ceiling, not an implementation detail, and a deployment approaching it needs a wider origin field negotiated through a feat token rather than a workaround.

    Allocating the host ordinal is the adapter’s responsibility. MemoryAdapter is a single host and uses ordinal 0. A cross-process adapter MUST provide a distinct ordinal per host and MUST NOT hand the same ordinal to two live hosts.

    Ordinals are recycled under the same rule, because autoscaling churns hosts and a 1,024-value space would otherwise exhaust for the same reason the counter would. When a host leaves, every session it owned ends, and receivers discard the corresponding sequence state within the 60-second retention window. An ordinal is therefore quarantined for at least 300 seconds before reallocation - longer than the per-origin quarantine because host departure is detected less promptly than session close, and because ordinal churn is slow enough that the extra margin costs nothing.

0x00000000 is reserved and MUST NOT be allocated, so a zero-filled buffer cannot parse as a valid datagram from a real peer.

Sequence is a u32 counter, monotonically increasing per (origin, event), starting at 1 and wrapping to 1 after 0xFFFFFFFF.

Receivers apply last-write-wins keyed on (origin, event): a datagram whose sequence is not greater than the highest already seen for that pair is discarded and counted.

Scoping to the origin rather than to the transport session is load-bearing under room fan-out, and the session-scoped alternative is broken in two distinct ways. A broadcast encodes one frame and hands the same bytes to every recipient (§ADR 0005: frames cross the adapter boundary as bytes, never re-encoded per recipient), so a sequence meaningful to one receiving session cannot be meaningful to another. And if the counter belonged to the receiving session, every originating peer would be multiplexed onto it: a peer that has been sending for a minute reaches sequence 3000, a peer that just joined starts at 1, and the newcomer’s datagrams are discarded forever as “not greater than the highest already seen”. Keying on the origin makes one encoding correct for every recipient and keeps each sender’s stream independent.

Origin is allocated by the server, never hashed - see the top of this section, which this paragraph contradicted for as long as both existed. The collision analysis that used to sit here described a design that was rejected precisely because it had one; it is kept below only as the argument against, and applies to no conforming implementation.

Were Origin a 32-bit hash, distinct peers would collide with probability approximately n² / 2³³ - about one in eight thousand at 1,000 concurrent peers, and about 1% at 10,000. A collision degrades rather than corrupts: two peers share a last-write-wins slot for one event, so one of them loses updates it should have kept. The unreliable lane already permits loss, so the trade is acceptable against four more header bytes on the lane whose whole purpose is being small.

This field is also what ADR 0005’s self-publish dedupe needs: a node receiving its own broadcast back identifies it by origin. Wrap is detected by treating the comparison as circular over the 32-bit space, with a difference greater than 0x7FFFFFFF read as wrap rather than regression.

This is in the protocol rather than left to applications. Most datagram payloads are last-write-wins: a cursor position, a presence beat, an object transform. Requiring every application to rebuild the check would leave the primitive too raw to use.

Duplicate suppression is unconditional. There is no per-event opt-out, and an implementation that wants to deliver duplicates would have to skip the check entirely. The sequence field is on the wire either way.

7.4 Size ceiling

The usable datagram size is a runtime property of the path, not a constant. It varies with path MTU, and some hosting platforms reduce it further - Fly.io, for example, documents taking roughly two dozen bytes off the MTU for its UDP routing. Implementations MUST query the transport at send time rather than assuming a fixed value.

The maximum payload is:

maxPayload = effectiveDatagramSize − 13

where effectiveDatagramSize is the transport’s reported maximum, or 1024 when the transport reports zero or does not report one. 1024 is the conservative floor, chosen because at least one major browser hardcodes exactly that value regardless of the true path MTU. The corresponding conservative payload maximum is 1011 bytes.

A sender MUST check the payload against this limit before writing. It MUST NOT rely on the transport to report an oversized datagram, because at least one widely used implementation accepts the write, discards the datagram, and reports success. Exceeding the limit raises WT_DATAGRAM_TOO_LARGE locally and transmits nothing.

7.5 What the unreliable lane does not guarantee

Stated explicitly, because inference is not good enough for a guarantee this load-bearing. On the unreliable lane:

  • Delivery is not guaranteed. Any datagram may be lost, and loss is not reported.
  • Ordering is not guaranteed. Datagrams may arrive in any order relative to each other.
  • Uniqueness is not guaranteed by the network. Duplicates are possible; §7.3 discards them at the receiver.
  • There is no acknowledgement, no retransmission and no delivery receipt.
  • There is no flow control feedback. A sender cannot learn that a receiver is behind.
  • Ordering relative to the reliable lane is not guaranteed. A datagram sent after an emit may arrive before it, and vice versa.
  • A datagram arriving before the handshake completes is discarded silently. This is consistent with every point above and is not an error condition.

Applications requiring any of these properties MUST declare the event on the reliable lane instead. The lane is declared in the contract precisely so this choice is explicit and visible in the type system.

7.6 What an unreliable event accepts on a fallback

A transport that carries only the reliable lane (§3.3) cannot deliver an unreliable event as §7.5 describes it. An unreliable event therefore declares, beside its lane in the contract, what it accepts there, and a session on such a transport exists only when every unreliable event has (§2). The declaration is local to each peer and is not carried in the handshake; each sender applies its own.

declarationon a reliable-only transport
noneno session is possible for this contract
newestcarried on the emit lane in order, with the oldest dropped on overflow and the stale dropped at dequeue as §9 already says, and counted in the same counters

8. Abort and stream reset

Cancelling a call maps to a QUIC stream reset carrying an application error code. It costs no application-level message and requires no cooperation from the peer, which is the main reason calls are modelled as streams.

The initiator resets its send side and stops reading; the responder observes the reset and SHOULD abandon the work.

Stream reset codes are one byte, values 0 to 255. This is a protocol-wide constraint, not an implementation limit: the WebTransport specification’s browser API clamps stream error codes to a single octet, so any wider code space would be untransmittable from a browser peer. Implementations MUST NOT define reset codes outside this range.


9. Backpressure and drop policy

Normative for a conforming sender, because these choices are observable to the peer.

laneboundon overflow
Datagram, per peer64 framesDiscard oldest, count it, do not error.
Emit, per peer256 framesClose the session with WT_PEER_TOO_SLOW.
Call stream32 frames of credit (§6.6)Producer waits for credit. Never discard.

The emit lane never discards. It advertises reliable, ordered delivery, so dropping a frame would misreport what happened to the application’s data. A peer 256 frames behind is disconnected instead.

Call streams neither queue unboundedly nor discard. A streaming responder holds at most the credit its consumer has granted (§6.6) and waits at zero; a single-response call writes one frame and is done. One slow consumer therefore stalls only its own stream, because each call owns its own QUIC stream.

Do not read that bound as the transport’s. An earlier draft of this section said that awaiting the writer applies flow control to the producer, which is the natural reading of the streams contract and is false on at least one shipping implementation: ready resolves unconditionally there, and a producer awaiting it ran 136,523 frames ahead of a consumer that had taken 40. The credit window in §6.6 exists because the transport cannot be relied on for this, and a conforming sender MUST NOT substitute the transport’s flow control for it.

Stale datagrams are a separate concern from overflow. A queued datagram older than its time-to-live is discarded at dequeue, default 150 ms. Overflow handles a burst; TTL handles a stall. Without TTL, a peer that stalls for two seconds and resumes receives a backlog of stale positions and renders history rather than the current state.

The two causes MUST be counted separately, as overflowDropped and staleDropped, so an operator can distinguish a slow network from a slow consumer.


10. Error codes

10.1 Stream reset codes

One byte. Sent as the QUIC application error code on RESET_STREAM or STOP_SENDING.

A reset carries a code and nothing else, so it is used only where there is no stream left to explain on. Everything a responder can say about a call - the handler threw, the event is not in the contract, the payload failed validation, the handshake had not completed - is sent as a CALL_ERROR frame (§6.4) carrying both a code and a message, on the stream the call already owns. That carries more detail than a reset code can, so only three reset codes exist.

codenamemeaning and remedy
0WT_NO_ERRORNormal termination. Implicit in a clean FIN; never sent explicitly.
1WT_ABORTEDThe initiator cancelled. Abandon the work; this is routine.
9WT_TOO_MANY_STREAMSOver 256 concurrent call streams on this session. The receiver resets the excess stream without reading it; the session stays open. Reduce concurrency and retry.
28, 10255reserved-

10.2 Session close codes

u32, sent in the WebTransport session close. The accompanying reason string MUST NOT exceed 1024 bytes, per the HTTP/3 WebTransport draft.

codenamemeaning and remedy
0WT_NO_ERRORNormal close.
1000WT_PROTOCOL_VERSION_MISMATCHPeers disagree on v. Upgrade one side.
1001WT_CONTRACT_MISMATCHAn event the peers share is declared differently. Reconcile that event.
1002WT_HANDSHAKE_TIMEOUTNo handshake within 5000 ms.
1003WT_PEER_TOO_SLOWEmit queue exceeded 256 frames. Consume faster.
1004WT_PROTOCOL_ERRORUnrecoverable framing violation.
1005WT_IDLE_TIMEOUTNothing received for 45000 ms on a mapping with an idle deadline (§3.3). The path is dead, or the peer sends no keepalive.
1006WT_RELIABILITY_REFUSEDSession was reliable-only, or on a fallback transport with an undeclared unreliable event in the contract. See §2.
1007WT_UNAUTHORIZEDThe listener’s authorize refused this peer. Sent before the server’s frame 0; the close reason is the application’s reason, 1 to 123 bytes (§4). Final: a client does not retry it.

On the WebSocket mapping these are carried as WebSocket close codes: 1000 for WT_NO_ERROR, and 3000 plus the code otherwise (§3.3).

10.3 Local codes

Raised by an implementation to its own application and never transmitted.

namemeaning and remedy
WT_NO_SUPPORTThe runtime has no WebTransport, or no WebSocket where a fallback was configured. A client with no fallback configured is unsupported on that runtime.
WT_LANE_UNAVAILABLEA call or a stream on a session over the WebSocket mapping (§3.3), which has no bidirectional streams. The session stays up.
WT_DATAGRAM_TOO_LARGEPayload exceeded §7.4. Shorten it, or move the event to the reliable lane.
WT_ROOM_NOT_JOINEDBroadcast to a room this session is not in. Join first.
WT_SESSION_CLOSEDThe session closed while the operation was pending. Reconnect and retry.
WT_HANDSHAKE_FAILEDThe transport-level handshake failed, and the origin either was not asked or did not answer over HTTPS. A browser reports one error for a wrong pinned hash, an expired certificate and an unreachable server alike, so the remedy names all three rather than guessing.
WT_UDP_UNREACHABLEThe transport-level handshake failed, and the same origin answered a HEAD at /.well-known/transport-io over HTTPS within 2000 ms. The server is up over TCP and only the QUIC path is failing: a firewall, a VPN, or a platform with no UDP ingress.
WT_CERT_EXPIREDA pinned development certificate is past its validity. Mint a new one and reload the client so it picks up the new hash.
WT_DEV_ONLYA development-only affordance was reached from somewhere that is not loopback.
WT_PORT_IN_USEA listener was asked to bind a port another process holds, probed before binding because the QUIC binding reports nothing. Stop that process or pass another port.

11. Session lifecycle

A session is established, handshakes (§4), carries traffic, and closes.

Reconnection creates a new session. Nothing is resumed: not the session identifier, not room membership, not pending calls. Pending calls reject with WT_SESSION_CLOSED. A new session may be on a different transport from the last (§3.3); a session never changes transport in place. Re-establishing application state after a reconnect is the application’s responsibility, and the session-resume feature token (§4.2) is reserved for a future version that changes this.

When a session closes, all its streams are closed and all pending calls reject. A peer that detects its counterpart has gone MUST NOT attempt to reuse any stream from that session.

A session also ends when its connection is lost: a killed process, a dead path, no close from the peer and so no §10.2 code. The WebTransport API reports that by rejecting the session’s closed promise, where a close resolves it. An implementation MUST treat the two alike: the session is over, pending calls reject, and the application is told. It reports WT_NO_ERROR, since no code was received, and a transport’s own code for a lost connection is never reported as a §10.2 code.