Internals: WebSockets

How a WebSocket behaves underneath: who owns the connection, what the host answers without telling you, and how a close actually completes.

For signatures see the Web APIs reference and the WebSocket guide. Framing is RFC 6455 via tokio-tungstenite.

One actor task per connection

Each connection is owned by a spawned actor task holding the split stream and running a select! loop over two channels:

TEXT
    ws.send()   ──→ command channel (16) ──→ ┐
                                             ├─→ actor task ──→ socket
    ws.onmessage ←── inbound channel (16) ←──┘

The ops never touch the socket; they send and receive on channels. This is the same shape as sockets and for the same reason: the I/O has to be driven by the reactor rather than by the event loop, which may be idle waiting for exactly the message that has not arrived.

Both channels hold 16 messages. A guest that stops reading eventually stalls the actor's forwarding, which stops draining the socket — backpressure reaching the peer through TCP, rather than an unbounded queue here.

What the host answers for you

Ping is answered with pong, in the host. The WebSocket IDL has no ping event, so a control frame that never reaches the guest is handled where it lands. A guest cannot observe a ping and cannot fail to answer one.

Pong and raw frames are dropped. Nothing in the guest API can act on them.

The runtime does not send pings of its own. There is no keepalive on a WebSocket connection and no idle timeout — a connection to a peer that vanished without a FIN stays open until the OS TCP keepalive notices it. That is the inverse of the HTTP/2 keepalive on the server side, and worth knowing if you hold long-lived client sockets.

Sends are coalesced into one write

When a command arrives, the actor does not write it and go back to sleep. It feeds that message to the sink and then drains whatever else is already queued before flushing once.

That matters for the fan-out shape — a broadcast that enqueues one frame per connected client — where the naive loop costs a socket write and a flush per frame. Coalescing turns a burst into a single write. A queue with one message in it behaves exactly as before.

Closing

The closing handshake is a handshake, not a hang-up, and the actor keeps running through it.

When the guest closes: the close frame is sent and the loop keeps going, so the peer's acknowledgement is still received and surfaced. Closing does not discard what is already queued — pending sends are drained and flushed first.

When the peer closes: the code and reason are forwarded to the guest, a close frame is sent back to complete the handshake, and the actor stops.

When the connection breaks — a stream error, or an end with no close frame — the actor drops the inbound channel. The next receive resolves to nothing, which the prelude turns into an abnormal close (1006). A peer that closes without a status code becomes 1005, per the specification.

When we close, the initiator is told what it asked for. The peer echoes the code and the host then tears the socket down, so the pump sees the same "stream ended with no frame" it sees for a broken connection — and reporting 1006 there made every ordinary close(4001, "bye") read as a dropped connection to its own handler, while the peer had correctly received 4001. The two cases are now distinguished by whether a close was requested: a requested one reports that code with wasClean: true (1005 when no code was given, matching what the peer sees), and an end nobody asked for is still 1006 with an error before it. 1006 must never mark a clean shutdown — reconnect logic keyed on the code took the failure branch every time.

wss:

TLS reuses the same stack as runtime:net: rustls with the aws-lc-rs provider named explicitly (both it and ring are linked, so the process default is ambiguous and would panic) and bundled webpki-roots.

The handshake is completed here, and the established stream handed to the WebSocket client — which means no TLS feature of tokio-tungstenite is compiled in, and one TLS stack is configured in one way across the whole runtime.

No ALPN is offered. The WebSocket upgrade rides plain HTTP/1.1, so there is nothing to negotiate. (HTTP/2 WebSockets — RFC 8441 extended CONNECT — are not implemented in either direction.)

Offered subprotocols go out on the upgrade request, and the negotiated one comes back off the response headers along with any extensions.

The server side

serve() binds a listener and runs an accept loop whose WebSocket handshake for each connection happens in its own task, so a slow or hostile handshake never blocks the next accept. A failed handshake ends that connection and nothing else.

The accept loop retries every accept error behind a doubling delay rather than ending — the same policy as the HTTP server and runtime:net. Accepted connections queue in a channel holding 64.

server.close() aborts that loop rather than signalling it. The loop parks in two places — on a connection permit, then on accept — and a closed channel is visible from neither until an accept returns, so at a full cap nothing arriving could ever wake it. Aborting releases the listening port immediately and resolves an accept() already waiting to null, which is what lets a for await (const ws of server) loop end. Connections already accepted are untouched: they are their own actor tasks, and they keep working until each is closed.

What bounds a connection

Two, and they are the HTTP server's mechanisms unchanged — see Internals: the HTTP server for the reasoning behind each.

LimitDefaultBounds
timeouts.handshake10sFrom accept until the opening handshake completes
maxConnectionsunlimitedHow many connections this server holds at once
maxConnectionsPerIpunlimitedHow many one peer address may hold
maxBufferedAmount8 MiBBytes queued for one connection before it is closed
Accept queue64Accepted connections waiting for an accept() call

The handshake bound exists because RFC 6455's handshake is an HTTP request head, and tungstenite will wait for one forever. A peer that completes the TCP handshake and then says nothing is the cheapest hold there is — one syscall to it, a task and a descriptor to us — so this is the same slowloris bound the HTTP server puts on the same bytes.

It stops there. An established connection has no deadline at all: no idle timeout, no keepalive, no maximum lifetime. A WebSocket that has said nothing for a week is idle, not stalled, and that is the whole point of the protocol — deciding when silence means a dead peer is the application's job, and the host answering ping for you (above) is the tool for it.

maxConnections is enforced by not accepting: a permit is taken before accept and released when the connection ends, so at the cap the acceptor simply stops and excess connections wait in the kernel's backlog, costing this server no descriptor, task or buffer. Held, not refused — when a slot frees the waiting connection is served.

The permit is released when the connection ends, not when its handshake finishes. That difference is the whole design: releasing it early would make the cap bound the handshake rate and nothing else, which is backwards for a server whose connections are long-lived. It is also why the cap matters more here than on an HTTP server. There, connection count is self-limiting — requests finish and connections close. Here it only goes up until your application closes something, so this option is what decides whether it has an upper bound at all.

How this compares

Measured by opening a TCP connection to each runtime's WebSocket port and never sending the upgrade request, and by establishing one connection against a cap of 1 and opening a second. Reproduce with bash bench/probe-ws-bounds.sh.

esrunNode.jsBunDeno
Connection that never sends a handshake, closed after10.0s89.8s13.0snever (>150s)
A connection over a cap of 1 isheldrefusedno capno cap
esrun 0.15.0 · Node 24.14.0 · Bun 1.4.0 (canary) · Deno 2.8.3 · Linux · 2026-08-03

The Node figure is a node:http server's own bound on a request head, because that is what actually bounds a ws handshake — ws attaches to a node:http server. It answers 408 before closing, and its ~90s comes from a 60s headersTimeout polled on a 30s interval.

Deno does not bound this at all, on the same hyper stack we use. Its serve options carry tcpBacklog and reusePort but no connection cap, and Bun's carry maxRequestBodySize but likewise no cap; passing maxConnections to either is silently ignored, which is what the "no cap" result records. (We carry reusePort too, on runtime:http serve() and runtime:net listen() — the contrast here is the connection cap, not the socket option.)

The row worth reading is the second one. Node has a cap — server.maxConnections — and reaching it resets the next connection immediately. Ours holds it: the connection stays open, costing this server nothing, and is served when a slot frees. A reset is easier to diagnose; a hold is the difference between a queue and a rejection, and it is what makes a cap safe to set conservatively.

maxConnectionsPerIp is the half maxConnections cannot answer: whose connections fill the budget. It matters more here than on an HTTP server for the same reason the total does — a WebSocket connection is long-lived by design, so one peer's share is not something churn takes back. It is taken after accept, where the peer is known, and an excess is refused rather than held: the whole-server cap queues because its excess is legitimate traffic, while an excess here is one client past its share, already holding a descriptor. Off by default, and behind a proxy it should stay off — every connection would carry the same source address.

Backpressure, and what happens when nobody looks

A connection's command channel holds 16 messages; past that the host-side send awaits a slot, but send() returns immediately either way — it is fire-and-forget by design, since the WebSocket API has no way to report a full buffer. So writing faster than a peer reads does not stall your code, it queues on the host, one pending send per message.

connection.bufferedAmount is what that queue looks like from the guest: bytes handed to send() that the host has not taken yet. A sender on a fan-out should read it and pace itself — the runtime will not do it for you.

maxBufferedAmount is what happens when it does not. The host counts queued bytes per connection, reserving before the message reaches the command channel (the parked sends are the thing being bounded, and counting only what the channel accepted would count none of them). Past the bound the connection is closed with 1013 — Try Again Later, which is exactly the situation — over a dedicated signal rather than a queued command, since the command channel is the thing that is full at that moment. So one peer that stops reading costs a connection instead of the process.

It is on by default at 8 MiB, unlike the connection caps, because the number does not depend on anything the deployment knows: 8 MiB undrained is already a peer several messages behind on a large payload, or thousands behind on a small one. Client connections carry the same default — a slow server is the same problem from the other end.

Two ways in

serve() binds a listener of its own and drives the handshake with tungstenite. The other way is an upgrade off the HTTP server (D55), which is how every peer runtime does it and the only way to get wss: today: TLS terminates on the HTTP listener, the request arrives as an ordinary one, and upgradeWebSocket() takes the connection over.

That crosses two provider seams, so it is joined in the ops layer rather than at either one: HttpServerProvider::upgrade surrenders the connection as a futures-io stream and WebSocketProvider::adopt takes it, and neither has to know the other exists. The ordering is the protocol's — hyper resolves the takeover only after the 101 is written, so the guest returns the response first and the socket's id arrives second, which is why the connection is built over a promise.

Two things the host does rather than the guest: the handshake headers (Sec-WebSocket-Accept is a digest of a key the handler never sees) and the accounting — an upgraded connection was already counted by the HTTP server that accepted it, so it takes no second connection slot and no second per-peer slot.

Still missing

WebSocket over HTTP/2 — RFC 8441 extended CONNECT. Over TLS this means a client must negotiate http/1.1 to upgrade, which browsers do for wss: and a client forcing h2 does not.

A failed handshake logs at debug on the runtime::websocket target, inside a debug span carrying the peer, so it is attributable to one client:

Shell
RUST_LOG=runtime::websocket=debug esrun server.js

debug rather than warn for the same reason as everywhere else here — a peer can produce these on demand, and a plain HTTP request to a WebSocket port is one of them. It is what tells a client sending the wrong handshake apart from a server nobody is calling. Accept errors stay at warn.

See also

Last updated on
Edit this page