WebSockets
Chat systems keep one long-lived connection per user so the server can push messages the moment they arrive. This page covers how that connection works, how it’s kept alive, and what it means for proxies in front of it.
How a WebSocket starts: the handshake
A WebSocket begins as an ordinary HTTP request asking to switch protocols:
GET /chat HTTP/1.1Host: chat.example.comUpgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Sec-WebSocket-Version: 13The server answers 101 Switching Protocols. From then on the same TCP connection stops speaking HTTP and speaks WebSocket, in both directions, for as long as it stays open. Because the protocol changes mid-connection, proxies need to know about WebSockets (below).
What travels over it: frames
Everything is sent as frames, each with a small header (2–14 bytes) giving its type and length:
| Frame type | Purpose |
|---|---|
| Text / Binary | Application data, e.g. chat messages. Almost all traffic. |
| Close | “I’m closing this connection”, with a status code and reason |
| Ping / Pong | “Are you there?” / “Yes”. Liveness checks, and keeping the connection from looking idle |
Overhead is tiny. A short message adds a 2-byte header. Frames from client to server also carry a 4-byte mask, a security measure against misbehaving intermediaries caching or misreading traffic. A ping is a few bytes every 20–30 seconds. Compare plain HTTP requests, which repeat hundreds of bytes of headers every time: after one handshake, each WebSocket message costs only a few bytes extra.
There’s no “from” field. A frame doesn’t say which user sent it: the connection itself is the user’s identity, established at the handshake. That matters for multiplexing.
Three kinds of keep-alive
“Keep-alive” names three unrelated mechanisms at different layers:
| Name | Layer | What it does | Reuses connections? |
|---|---|---|---|
| HTTP keep-alive (persistent connections) | HTTP | Keeps a TCP connection open after a response so the next request can reuse it | Yes |
TCP keepalive (e.g. curl --keepalive-time) |
OS / TCP | On an idle connection, the OS sends small probes to check the other side is still there | No |
| WebSocket ping/pong | WebSocket | Small frames inside an open WebSocket to stop idle timeouts and spot dead peers | No (there’s only one connection) |
Two consequences that often confuse people:
- Separate
curlcommands never share a connection, whatever--keepalive-timesays. A TCP connection belongs to the process that opened it; whencurlexits, it closes. Reuse happens only within one process:curl -v URL URLprintsRe-using existing connection. A long-running proxy, by contrast, keeps its pool across requests. - HTTP keep-alive doesn’t apply to a WebSocket: after the upgrade it’s one long connection that never closes between messages, so there’s nothing to reuse.
Who sends pings, and why not TCP keepalive
The protocol defines ping and pong, and the receiver of a ping answers with a pong automatically (libraries do this for you). Something still has to send pings. Browser JavaScript can’t, so in practice the server pings each client every 20–30 seconds and closes connections that don’t answer.
Why not rely on TCP keepalive? The OS waits a long time before probing an idle connection: the Linux default is 2 hours. Load balancers close idle connections much sooner (AWS’s Application Load Balancer defaults to 60 seconds), so a quiet chat would be cut long before TCP noticed anything. WebSocket pings on a 20–30 second interval keep the connection looking active and detect dead clients quickly.
Use a library, own the policy
Go’s standard library has no WebSocket server (the older golang.org/x/net/websocket package is discouraged), so use a maintained library such as github.com/coder/websocket. Hand-writing frame parsing teaches little about chat systems. The split:
| The library does (protocol mechanics) | Your code decides (policy) |
|---|---|
The handshake (101 Switching Protocols) |
How often to ping (e.g. every 20s) |
| Building and parsing frames | How long to wait for a pong |
| Sending a ping when asked; answering pings with pongs | What to do when no pong arrives (close, mark the user offline) |
The policy is a small loop per connection:
// one per connected user, in its own goroutineticker := time.NewTicker(20 * time.Second)defer ticker.Stop()for range ticker.C { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) err := conn.Ping(ctx) // sends a ping and waits for the pong cancel() if err != nil { conn.Close(websocket.StatusGoingAway, "no pong") // peer is gone return }}The right interval depends on your setup, such as the load balancer’s idle timeout, which is why libraries leave it to you.
Passing WebSockets through a proxy
A plain HTTP reverse proxy thinks in request/response pairs: read a request, forward it, relay the response, done. A WebSocket breaks that model: after 101, frames flow both ways, whenever either side has something, for hours. A proxy waiting for “the next request” hangs or closes the connection, and chat fails as soon as it sits behind it.
Supporting WebSockets doesn’t require understanding them:
- Notice the
Upgrade: websocketheader on a request. - Forward the handshake to a backend as usual.
- After the backend’s
101, stop treating the connection as HTTP: take over the raw TCP connection (Go calls this hijacking, viahttp.Hijacker) and copy bytes in both directions until either side closes. In Go that’s two goroutines, each runningio.Copy.
No frames are parsed and no pings are sent: for that connection the L7 proxy behaves like an L4 one. Products need it enabled explicitly in places; Nginx, for example, needs the Upgrade and Connection headers passed through in its config.
Deploys and reconnects
- A WebSocket never “finishes”, so deploys must cut connections. If every client reconnects at once, the result is a reconnect storm; clients need random reconnect delays (jitter).
- Better: before shutting down, the server tells its clients to reconnect elsewhere, spread over a few minutes. More in L7 proxy vs direct (draining row).
Interview version
“WebSockets start as an HTTP upgrade, then carry small frames both ways over one connection. The server pings every 20–30 seconds, because load balancers close idle connections after about a minute and TCP keepalive is far too slow. Proxies must pass the upgrade through and then just copy bytes. Deploys cut connections, so clients reconnect with jitter.”
Sources
- RFC 6455: The WebSocket Protocol (handshake, framing, masking, ping/pong)
- coder/websocket and its Go package docs
- Go net/http package (
Hijacker) - Linux kernel: IP sysctl (
tcp_keepalive_time, default 2 hours) - curl manual (
--keepalive-time) - AWS: Application Load Balancers (idle timeout, default 60 seconds)