Why WebSocket
HTTP is request/response: the server cannot push an update on its own. Before WebSocket, developers used polling (a request every 5 seconds) or long-polling. Both waste CPU and connections. WebSocket gives you one TCP connection through which both the client and the server send messages whenever they like.
LinuxLab uses WebSocket at /api/ws/sessions/{id} for the PTY terminal and
state updates from the agent inside the container.
Handshake
It starts as a normal [[http-protocol|HTTP/1.1]] request with an upgrade:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
The server replies with 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Accept = base64(SHA1(client_key + magic_GUID)). This is not
a security mechanism. It protects against accidental upgrades by intermediaries
that do not understand WebSocket.
After 101, the same TCP socket carries the traffic, but the protocol has changed: binary WebSocket frames flow from this point, not HTTP.
Frame format
0 1 2 3 4 5 6 7
+-+-+-+-+---+---+-+-+---+---+----+
|F|R|R|R| op|M| payload-len |...|
|I|S|S|S|cod|A| | |
|N|V|V|V|e |S| | |
| |1|2|3| |K| | |
+-+-+-+-+---+-+-+--------------+
- FIN - is this the last frame in a logical message? (messages can be fragmented)
- opcode - 0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong
- MASK - client to server must mask the payload with XOR; server to client does not mask
- payload-len - 7 / 16 / 64 bits (extended when length exceeds 125)
Masking from the client protects against cache-poisoning of old HTTP proxies.
ws:// vs wss://
ws://- plain TCP, port 80wss://- TLS-wrapped, port 443
In production, always use wss://. The WebSocket handshake lives inside a
[[tls-handshake|TLS session]]: it looks like an HTTPS request, and the upgrade
happens inside the encrypted channel.
Ping / Pong
To confirm the connection is alive (NAT not closed, peer not down), either side sends a ping frame (opcode 0x9). The receiver must return a pong (0xA) with the same payload. If pong does not arrive within N seconds, the connection is considered dead and a reconnect is needed.
Typical interval: 30-60 seconds (shorter than the NAT timeout, which is usually 5 minutes for UDP NAT and 2 hours for TCP NAT).
Close
A clean shutdown uses a close frame (0x8) with a 2-byte status code:
| Code | Meaning |
|---|---|
| 1000 | normal closure |
| 1001 | going away (page closed) |
| 1002 | protocol error |
| 1003 | unsupported data |
| 1006 | abnormal closure (never sent explicitly; means the connection was dropped) |
| 1011 | server error |
| 4000+ | application-defined |
A well-written client responds to 1006 with exponential backoff reconnect.
Backpressure
WebSocket has no native flow control at the message level (only the TCP window). If the server sends faster than the client can consume, the buffer grows. Under heavy load you need either application-level backpressure (acks from the client) or a drop-message policy.
Message size
Technically up to 2^63 bytes. In practice, servers enforce a limit of 1-16 MB. Send large files over HTTP, not over WebSocket.
When things go wrong
- Connection closed before handshake - the reverse proxy did not forward the
Upgradeheader. In nginx you needproxy_set_header Upgrade $http_upgrade; - 1006 every 60 seconds - the load balancer closes idle connections. Add a ping or increase the LB timeout
- Stuck after 1 message - the client forgot to mask; the server dropped the frame
- Memory growth - no backpressure; slow clients accumulate buffer
- Echo back to server - missing
Originheader validation; cross-site WebSocket hijacking