The WebTransport API provides a modern update to WebSockets, transmitting data between client and server using HTTP/3 Transport. WebTransport provides support for multiple streams, unidirectional streams, and out-of-order delivery. It enables reliable transport via streams and unreliable transport via UDP-like datagrams.
HTTP/3 has been in progress since 2018. It is based on Google's QUIC protocol (which is itself based on UDP), and fixes several issues around the classic TCP protocol, on which HTTP and WebSockets are based.
These include:
- Head-of-line blocking: HTTP/2 allows multiplexing, so a single connection can stream multiple resources simultaneously. However, if a single resource fails, all other resources on that connection are held up until any missing packets are retransmitted. With QUIC, only the failing resource is affected.
- Faster performance: QUIC is more performant than TCP in many ways. QUIC can handle security features itself, rather than handing responsibility off to other protocols like TLS, meaning fewer round trips. And streams provide better transport efficiency than the older packet mechanism. This can make a significant difference, especially on high-latency networks.
- Better network transitions: QUIC uses a unique connection ID to handle the source and destination of each request to ensure that packets are delivered correctly. This ID can persist between different networks, meaning that for example a download can continue interrupted if you switch from Wifi to a mobile network. HTTP/2 on the other hand uses IP addresses as identifiers, so network transitions can be problematic.
- Unreliable transport: HTTP/3 supports unreliable data transmission via datagrams.
The WebTransport API provides low-level access to two-way communication via HTTP/3, taking advantage of the above benefits, and supporting both reliable and unreliable data transmission.
To open a connection to an HTTP/3 server, you pass its URL to the WebTransport()
constructor. Note that the scheme needs to be HTTPS, and the port number needs to be explicitly specified. Once the WebTransport.ready
promise fulfills, you can start using the connection.
Also note that you can respond to the connection closing by waiting for the WebTransport.closed
promise to fulfill. Errors returned by WebTransport operations are of type WebTransportError
, and contain additional data on top of the standard DOMException
set.
const url = "https://example.com:4999/wt";
async function initTransport(url) {
const transport = new WebTransport(url);
await transport.ready;
}
async function closeTransport(transport) {
try {
await transport.closed;
console.log(`The HTTP/3 connection to ${url} closed gracefully.`);
} catch (error) {
console.error(`The HTTP/3 connection to ${url} closed due to ${error}.`);
}
}
"Unreliable" means that transmission of data is not guaranteed, nor is arrival in a specific order. This is fine in some situations and provides very fast delivery. For example, you might want to transmit regular game state updates where each message supersedes the last one that arrives, and order is not important.
Unreliable data transmission is handled via the WebTransport.datagrams
property — this returns a WebTransportDatagramDuplexStream
object containing everything you need to send datagrams to the server, and receive them back.
The WebTransportDatagramDuplexStream.writable
property returns a WritableStream
object that you can write data to using a writer, for transmission to the server:
const writer = transport.datagrams.writable.getWriter();
const data1 = new Uint8Array([65, 66, 67]);
const data2 = new Uint8Array([68, 69, 70]);
writer.write(data1);
writer.write(data2);
The WebTransportDatagramDuplexStream.readable
property returns a ReadableStream
object that you can use to receive data from the server:
async function readData() {
const reader = transport.datagrams.readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
console.log(value);
}
}
"Reliable" means that transmission and order of data are guaranteed. This provides slower delivery (albeit faster than with WebSockets), and is needed in situations where reliability and ordering are important, like chat applications.
To open a unidirectional stream from a user agent, you use the WebTransport.createUnidirectionalStream()
method to get a reference to a WritableStream
. From this you can get a writer
to allow data to be written to the stream and sent to the server.
async function writeData() {
const stream = await transport.createUnidirectionalStream();
const writer = stream.writable.getWriter();
const data1 = new Uint8Array([65, 66, 67]);
const data2 = new Uint8Array([68, 69, 70]);
writer.write(data1);
writer.write(data2);
try {
await writer.close();
console.log("All data has been sent.");
} catch (error) {
console.error(`An error occurred: ${error}`);
}
}
Note also the use of the WritableStreamDefaultWriter.close()
method to close the associated HTTP/3 connection once all data has been sent.
If the server opens a unidirectional stream to transmit data to the client, this can be accessed via the WebTransport.incomingUnidirectionalStreams
property, which returns a ReadableStream
of WebTransportReceiveStream
objects. Each one can be used to read Uint8Array
instances sent by the server.
In this case, the first thing to do is set up a function to read a WebTransportReceiveStream
. These objects inherit from the ReadableStream
class, so can be used in just the same way:
async function readData(receiveStream) {
const reader = receiveStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
console.log(value);
}
}
Next, call WebTransport.incomingUnidirectionalStreams
and get a reference to the reader available on the ReadableStream
it returns, and then use the reader to read the data from the server. Each chunk is a WebTransportReceiveStream
, and we use the readFrom()
set up earlier to read them:
async function receiveUnidirectional() {
const uds = transport.incomingUnidirectionalStreams;
const reader = uds.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
await readData(value);
}
}
Bidirectional transmission
To open a bidirectional stream from a user agent, you use the WebTransport.createBidirectionalStream()
method to get a reference to a WebTransportBidirectionalStream
. In the same way as the WebTransportDatagramDuplexStream
, this contains readable
and writable
properties returning references to ReadableStream
and WritableStream
instances, which can be used to read from and write to the server.
async function setUpBidirectional() {
const stream = await transport.createBidirectionalStream();
const readable = stream.readable;
const writable = stream.writable;
...
}
Reading from the ReadableStream
can then be done as follows:
async function readData(readable) {
const reader = readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
console.log(value);
}
}
And writing to the WritableStream
can be done like this:
async function writeData(writable) {
const writer = writable.getWriter();
const data1 = new Uint8Array([65, 66, 67]);
const data2 = new Uint8Array([68, 69, 70]);
writer.write(data1);
writer.write(data2);
}
If the server opens a bidirectional stream to transmit data to and receive it from the client, this can be accessed via the WebTransport.incomingBidirectionalStreams
property, which returns a ReadableStream
of WebTransportBidirectionalStream
objects. Each one can be used to read and write Uint8Array
instances as shown above. However, as with the unidirectional example, you need an initial function to read the bidirectional stream in the first place:
async function receiveBidirectional() {
const bds = transport.incomingBidirectionalStreams;
const reader = bds.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
await readData(value.readable);
await writeData(value.writable);
}
}