The outbound side of one server WebSocket connection — a byte-budgeted
queue of frames, the pump that hands them to the socket, and the deadlines
on what the socket has not yet written.
Every frame the server sends a connection goes through
WsOutboundWriter.push: a response, an error frame, a notification, a
broadcast, a server-initiated request. BackendWebsocketTransport owns one
writer per connection and is its only caller.
What waits
A frame pushed at an idle connection — nothing waiting, nothing unwritten in
the runtime's socket buffer, and no other frame pushed in the same
synchronous run — goes straight to the socket. Every other frame waits
in the writer's queue: the rest of a burst pushed in one synchronous run
(a loop with no await in it gives the pump no turn), and anything pushed
while the socket's buffer is at or over WS_WRITE_HIGH_WATER_BYTES. The
pump hands waiting frames over in order — on the microtask after a push, and
whenever the buffer has drained below the high-water mark.
What waits is held to the connection's budget (OutboundBudget): each
waiting frame is charged its UTF-8 length plus
OUTBOUND_FRAME_OVERHEAD_BYTES, and a frame is always admitted when nothing
is waiting. A frame handed to the socket is no longer charged — the budget
bounds what waits, and the write deadline bounds what is in flight.
A frame that does not fit is reported as an overflow and not queued; the
owner closes the connection for it (WS_CLOSE_OUTBOUND_OVERFLOW).
The queue is a linked list: a frame handed to the socket is unlinked, so the
writer holds the frames that wait and no others, however long the queue goes
without emptying.
Closing
close sends the close frame and takes nothing more. With 'flush' it
first runs the pump's rule once: waiting frames are handed to the socket
while its buffer is below WS_WRITE_HIGH_WATER_BYTES. Whatever still waits
is discarded, with either backlog choice. So a frame sent to an otherwise
idle connection just before a close reaches the client ahead of the close
frame, and a frame queued behind a backed-up socket does not. After a close
a socket whose buffer the writer can read ("Runtimes") holds less than the
high-water mark plus one frame, and the close frame.
Deadlines
- A frame: bytes the socket's buffer still holds
WS_WRITE_STALL_TIMEOUT after they were handed to it mean the client is
not reading. The writer discards what waits, reports the stall, and cuts
the socket with no close frame (the client sees an abnormal close, 1006).
- A close frame: a close still unwritten WS_CLOSE_WRITE_TIMEOUT after
close cuts the socket the same way, so a closed connection whose client
stopped reading does not hold its buffered frames for the runtime's own
close timeout — where the runtime can cut a socket ("Runtimes").
Runtimes
The buffer is read through the socket Hono's WSContext wraps (raw):
bufferedAmount on Node's ws and on Deno's standard WebSocket,
getBufferedAmount() on Bun's ServerWebSocket. A socket that exposes
neither reads as never backed up: its frames are still budgeted while they
wait for the pump, but nothing detects a stall. The cut is terminate(),
which Node's ws and Bun's ServerWebSocket have. Deno's standard
WebSocket has no way to drop a socket without the closing handshake, so
there a stalled connection is closed with WS_CLOSE_OUTBOUND_OVERFLOW
instead — a frame the client reads only if it starts reading again — and
the close deadline cuts nothing: a closed socket whose client is not reading
stays in the runtime, holding what its buffer held at the close ("Closing"),
until the runtime or the client ends it.