WebSocket

The classic WHATWG WebSocket — a client as a global (like fetch) and a server as runtime:websocket. Push-based message/close events ride the runtime's tick; wss: reuses the same TLS stack as runtime:net.

Client

The WebSocket global — no import.

Capability: Net

Global client over ws: / wss:.

JavaScript
// The WebSocket global — like fetch, no import. Opening requires Net.
const ws = new WebSocket("wss://example.com/socket", ["chat"]);
ws.binaryType = "arraybuffer"; // or "blob" (default)

ws.addEventListener("open", () => ws.send("hello"));
ws.addEventListener("message", (e) => {
  // e.data: string (text) | ArrayBuffer | Blob (binary, per binaryType)
  console.log(e.data, e.origin);
});
ws.addEventListener("close", (e) => console.log(e.code, e.reason, e.wasClean));

ws.close(1000, "done"); // code 1000 or 3000–4999; reason ≤ 123 UTF-8 bytes
Net capability

Without the Net capability (or with no WebSocket provider installed) the socket fails with an error then a close (code 1006).

Interface

MemberTypeDescription
new WebSocket(url, protocols?)(url, string | string[]) => WebSocketurl must be ws:/wss: with no fragment; protocols are RFC 6455 tokens. Requires Net.
readyState0 | 1 | 2 | 3CONNECTING / OPEN / CLOSING / CLOSED — constants on the instance and the interface.
send(data)(BufferSource | Blob | USVString) => voidThrows InvalidStateError while CONNECTING; dropped silently after close.
close(code?, reason?)(number?, string?) => voidcode = 1000 or 3000–4999 (else InvalidAccessError); reason ≤ 123 UTF-8 bytes (else SyntaxError).
binaryType"blob" | "arraybuffer"How binary messages surface in message events (default "blob").
bufferedAmountnumberBest-effort bytes queued by send but not yet flushed.
protocol / extensions / urlstringNegotiated subprotocol / extensions ("" — none) / the resolved URL.
on{open,message,error,close}EventHandlerAlso via addEventListener. message → MessageEvent; close → CloseEvent.

Server · runtime:websocket

Serving is capability-gated I/O, so it lives in a runtime: module like runtime:net listen(). serve() yields accepted connections — each the same shape as a client socket, already open.

Capability: NetListen

Exposed as an ES module over ws:.

JavaScript
import { serve, broadcast } from "runtime:websocket";

const clients = new Set();
const server = serve({ hostname: "127.0.0.1", port: 4001 });
const { port } = await server.addr;

for await (const ws of server) {
  clients.add(ws);
  // broadcast() fans out in one host crossing — full delivery, coalesced writes.
  ws.addEventListener("message", (e) => broadcast(clients, e.data));
  ws.addEventListener("close", () => clients.delete(ws));
}

Exports

ExportTypeDescription
serve(options)({ hostname?, port, timeouts?, maxConnections?, maxConnectionsPerIp?, maxBufferedAmount? }) => WebSocketServerBind a WebSocket server (ws: only). hostname defaults to 0.0.0.0 (all interfaces — pass 127.0.0.1 for a loopback-only server; a locked-down host may refuse a wildcard bind). port 0 picks an ephemeral port (read it from server.addr). NetListen.
upgradeWebSocket(request, options?)(Request, { protocol? }) => { response, socket }Turn a runtime:http request into a WebSocket, so one port serves https: and wss:. Return response to accept, anything else to decline. NetListen is already held by the server.
broadcast(connections, data)(Iterable<conn>, string | BufferSource | Blob) => voidSend one message to many connections in a single host crossing — the batched form of a .send() loop (concurrent enqueue, coalesced writes, full delivery). A closed connection is skipped; an element that is not a connection is a TypeError.

ServeOptions

OptionDefaultDescription
hostname"0.0.0.0"Address to bind.
port00 picks an ephemeral port — read it back from server.addr.
timeouts.handshake10000Milliseconds from accept until the opening handshake completes; null disables.
maxConnectionsunlimitedThe most connections to hold at once; over the cap they wait rather than being refused.
maxConnectionsPerIpunlimitedThe most one peer address may hold; over this they are refused. Leave off behind a proxy.
maxBufferedAmount8 MiBBytes that may sit queued for one connection before it is closed with 1013. On by default; 0 removes it.
JavaScript
const server = serve({
  port: 4001,
  timeouts: { handshake: 5_000 },
  maxConnections: 10_000,
});

timeouts.handshake bounds only the opening handshake — RFC 6455's is an HTTP request head and a 101 answer, so this is the slowloris bound on those bytes. It never reaches an established connection: a socket that has said nothing for a week is idle, not stalled, and closing it is your application's decision.

maxConnections is worth setting on a public port, more so than on an HTTP server. HTTP connections churn; WebSocket connections are long-lived by design, so the count does not fall back down on its own. Over the cap a connection is held, not refused — it waits in the kernel's backlog, costing the server no descriptor, task or buffer, and is served the moment a slot frees. Unlimited by default, because the right number follows from your file-descriptor budget, which the runtime cannot read.

One port for https: and wss:

serve() binds a WebSocket server on its own port. A service that already has an HTTP server does not need a second one — take the connection over instead, the way Node, Deno and Bun all do:

JavaScript
import { serve } from "runtime:http";
import { upgradeWebSocket, broadcast } from "runtime:websocket";

const room = new Set();

serve({ port: 443, secureTransport: "on", cert, key }, (request) => {
  if (request.headers.get("upgrade") === "websocket") {
    const { response, socket } = upgradeWebSocket(request);
    room.add(socket);
    socket.onmessage = (e) => broadcast(room, e.data);
    socket.onclose = () => room.delete(socket);
    return response;
  }
  return new Response("api");
});

The socket is usable immediately; sends before the handover completes are queued. It is an ordinary connection — broadcast() reaches it alongside sockets from serve(), and maxBufferedAmount applies at its default. The handshake headers are the host's: Sec-WebSocket-Accept is a digest of a key your handler never sees. A Request that did not come from a handler is a TypeError.

Answer a subprotocol by naming one the client offered — anything else is a TypeError here rather than a handshake the client silently rejects:

JavaScript
const { response, socket } = upgradeWebSocket(request, { protocol: "chat.v2" });
socket.protocol; // "chat.v2"
Over TLS the client must negotiate http/1.1

Browsers do this for wss:, so this is invisible in practice. WebSocket over HTTP/2 needs RFC 8441 extended CONNECT, which is not implemented — a client that forces h2 gets whatever your handler returns for a non-upgrade request rather than a 101.

Backpressure

send() is fire-and-forget — the WebSocket API has no way to report a full buffer — so writing faster than a peer reads never stalls your code. The messages queue on the host instead, one pending send each.

connection.bufferedAmount is what a sender can feel: bytes handed to send() that the host has not taken yet.

JavaScript
for await (const chunk of source) {
  if (conn.bufferedAmount > 1 << 20) break; // this peer is a megabyte behind
  conn.send(chunk);
}

maxBufferedAmount is what happens when nobody looks: past it the host closes that connection with 1013 (Try Again Later) rather than hold more, so one slow peer costs a connection instead of the process. It applies per connection, including the ones broadcast() fans out to. Unlike the connection caps it is on by default, because the number does not depend on what the deployment knows.

maxConnectionsPerIp is the half that says whose connections those are — without it one peer can take every slot, and the server is then full for everybody. Over this cap a connection is refused rather than held: the excess is one client past its share, and holding it is the hold the cap exists to prevent. Leave it off behind a proxy or a NAT, where every connection carries the same source address and a cap here would cap the whole service.

Both options are spelled exactly as runtime:http's serve() spells them.

WebSocketServer

MemberTypeDescription
addrPromise<{ hostname, port }>The bound address (resolves once listening).
accept()Promise<connection | null>The next connection, or null once closed.
close()Promise<void>Stop accepting new connections.
[Symbol.asyncIterator]AsyncIterable<connection>for await (const ws of server) { … }

connection

MemberTypeDescription
send(data)(string | Blob | BufferSource) => voidSend a text or binary frame.
close(code?, reason?)(number?, string?) => voidBegin the closing handshake.
binaryType"blob" | "arraybuffer"How binary messages surface (default "blob").
on{message,close,error}EventHandlerAlso via addEventListener — the client surface, minus the connecting handshake.

Errors

ErrorWhen
SyntaxErrornew WebSocket(url) with an invalid URL or scheme, or close(code, reason) with a reason longer than 123 UTF-8 bytes.
DOMExceptionname "InvalidAccessError"close(code) with a code other than 1000 or 3000–4999.
DOMExceptionname "InvalidStateError"send() while the socket is still CONNECTING.
(event)A failed connection or a denied Net capability surfaces as an error event followed by a close (code 1006) — not a thrown exception.
Not yet

The promise/stream-based WebSocketStream, permessage-deflate (extensions is always ""), a wss: server, and pub/sub topics over the explicit-set broadcast().

Last updated on
Edit this page