The WebSocket API provides a JavaScript interface to the RFC6455 WebSocket protocol. While it has served well, it is awkward from an ergonomics perspective and is missing the important feature of backpressure. In particular,
WebSocketStream aims to solve these deficiencies with a new API.
Here’s a basic example of usage of the new API:
const wss = new WebSocketStream(url);
const { readable } = await wss.opened;
const reader = readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done)
break;
await process(value);
}
done();
This is the roughly equivalent code with the old API:
const ws = new WebSocket(url);
ws.onmessage = evt => process(evt.data);
ws.onclose = evt => evt.wasClean ? done() : signalErrorSomehow();
The major difference is that the second example won’t wait for asynchronous
activity in process()
to complete before calling it again; it will keep
hammering it as long as messages keep arriving.
Also note that because the old API was designed before Promises were added to the language, error-handling is awkward.
Writing also uses the backpressure facilities of the Streams API:
const wss = new WebSocketStream(url);
const { writable } = await wss.opened;
const writer = writable.getWriter();
for await (const message of messages) {
await writer.write(message);
}
The second argument to WebSocketStream is an option bag to allow for future extension. One option is “protocols”, which behaves the same as the second argument to the WebSocket constructor:
const wss = new WebSocketStream(url, {protocols: ['chat', 'chatv2']});
const { protocol } = await wss.opened;
The selected protocol is part of the dictionary available via the
wss.opened
promise, along with “extensions”. All the information about
the live connection is provided by this promise, since it is not relevant if the
connection failed.
const { readable, writable, protocol, extensions } = await wss.opened;
The information that was available from the onclose and onerror events in the old API is now available via the “closed” Promise. This rejects in the event of an unclean close, otherwise it resolves to the code and reason sent by the server.
const { closeCode, reason } = await wss.closed;
An AbortSignal passed to the constructor makes it simple to abort the handshake.
const wss = new WebSocketStream(url, { signal: AbortSignal.timeout(1000) });
The close method can also be used to abort the handshake, but its main purpose is to permit specifying the code and reason which is sent to the server.
wss.close({closeCode: 4000, reason: 'Game over'});
There is a 1:1 mapping between WebSocket messages and stream chunks.
Each call to read()
returns one WebSocket message. If a message is split into
multiple frames on the wire, it won't be returned by read()
until the final
frame (the one with the FIN flag set) arrives.
When read()
is not called, the browser and operating system will still buffer
data to some extent, so backpressure will not be detected immediately by the
server.
Text messages appear in JavaScript as strings. Binary messages appear as Uint8Array objects.
A clean close will result in read()
returning an object with done
set to
true. An unclean close will result in a rejected promise.
Each call to write()
(or chunk that is piped into the writable
) will be
converted to one message. The browser may split the message into multiple
frames. BufferSource (ArrayBuffer or TypedArray) objects will be sent as binary
WebSocket messages. Any other type will be converted to a string and sent as a
text message.
The promise returned by write()
will resolve when the message has been
buffered (either by the browser or operating system). The size of the buffer is
finite but unspecified. It is not a signal that the message has been delivered
to the WebSocket server (the browser does not have this information).
The promise returned by write()
will reject if the connection is closed or
errored.
Applications written with the new API will automatically be more responsive due to respecting backpressure. High throughput applications will adapt to the capabilities of the client, providing everyone with a smooth experience.
It’s possible to implement backpressure at the application level, but it’s complex and difficult to achieve peak throughput. For example, client JavaScript could send an application-level confirmation message to the server every time it finishes processing a message. The server could keep track of how many messages it has sent that have not yet been confirmed, and stop sending if the number gets above a certain threshhold.
Aside from backpressure, the rest of the API can be emulated by wrapping the existing WebSocket API, but this will not permit future extensions.
Adding new attributes to the existing WebSocket API was considered but not adopted because having two APIs on one object would be confusing and create odd semantics.
WebTransport also provides backpressure, and may replace WebSocket for many purposes. In the near future the WebSocket protocol has the advantage that it works on networks that block QUIC, and has much existing deployed infrastructure.
An older version of this explainer had the readable stream producing ArrayBuffer chunks. This was changed to Uint8Array chunks to align better with WebTransport and modern practice.
Previously the closeCode
attribute was called code
, but this conflicted with
the code
attribute of DOMException
.