Skip to content

WebSocket

I'm bored of writing so much documentation. Check out the next page on moq-lite instead; it's way more interesting.

Here's some AI slop for now:

AI SLOP

WebSocket is a TCP fallback for when QUIC/WebTransport isn't available. This happens more often than you'd think: corporate firewalls love blocking UDP, and Safari didn't support WebTransport until 26.4.

We use a thin polyfill called web-transport-ws that emulates the WebTransport API over a WebSocket connection. It multiplexes streams using a simple binary framing protocol, so the rest of the stack doesn't need to know or care that it's running over TCP.

How It Works

When establishing a connection, the client races QUIC/WebTransport against WebSocket in parallel. WebSocket wins the race when UDP is blocked or WebTransport isn't supported.

The polyfill converts the URL scheme (https://wss://, http://ws://) and connects with the webtransport subprotocol. Once connected, it provides the same API as WebTransport: bidirectional streams, unidirectional streams, and connection management.

Obviously it won't perform as well as QUIC during congestion — everything gets head-of-line blocked over TCP. But it's better than not working at all.

Streams

Stream IDs are encoded as variable-length integers with the lower 2 bits indicating the stream type:

Bit 0Bit 1Type
00Client-initiated bidirectional
10Server-initiated bidirectional
01Client-initiated unidirectional
11Server-initiated unidirectional

This matches the QUIC stream ID scheme.

Framing

Each WebSocket binary message contains a single frame. The frame type is identified by the first byte, borrowing values from the QUIC spec:

STREAM (0x08) / STREAM_FIN (0x09)

Carries data for a stream. STREAM_FIN indicates the final data on the stream.

text
+--------+-----------+---------+
| Type   | Stream ID | Payload |
| 1 byte | VarInt    | ...     |
+--------+-----------+---------+

No length field is needed since WebSocket already provides message boundaries.

RESET_STREAM (0x04)

Abruptly terminates the sending side of a stream with an error code.

text
+--------+-----------+------------+
| Type   | Stream ID | Error Code |
| 1 byte | VarInt    | VarInt     |
+--------+-----------+------------+

STOP_SENDING (0x05)

Requests the peer stop sending on a stream. The peer should respond with a RESET_STREAM.

text
+--------+-----------+------------+
| Type   | Stream ID | Error Code |
| 1 byte | VarInt    | VarInt     |
+--------+-----------+------------+

APPLICATION_CLOSE (0x1d)

Gracefully closes the connection with an error code and reason.

text
+--------+------------+--------+
| Type   | Error Code | Reason |
| 1 byte | VarInt     | UTF-8  |
+--------+------------+--------+

VarInt Encoding

Variable-length integers use the QUIC VarInt encoding. The first two bits indicate the length:

PrefixLengthMax Value
001 byte63
012 bytes16,383
104 bytes1,073,741,823
118 bytes4,611,686,018,427,387,903

Limitations

Let's be real: this is a polyfill, not a replacement.

  • Head-of-line blocking: TCP delivers bytes in order, so a lost packet stalls everything. The whole point of QUIC streams is to avoid this.
  • No prioritization: The sender can't choose which stream gets bandwidth first. With QUIC, we prioritize new video over old video — over WebSocket, they're all stuck in line.
  • No partial reliability: You can reset a logical stream, but the bytes already in the TCP buffer will still be delivered (and block everything behind them).

It's good enough for low-congestion scenarios and ensures your app works everywhere. For the best experience, use a browser that supports WebTransport.

Future

There is a WebTransport over HTTP/2 draft that could replace this WebSocket polyfill. It's too little, too late for now, but maybe one day. In the meantime, WebSocket is the only reliable fallback for Safari and older iOS devices.

Licensed under MIT or Apache-2.0