Most HTTP requests are one-shot: the client asks, the server answers, and the connection may end. A WebSocket upgrades a connection so either side can send at any time. Picking it over polling is a decision about whether updates should be pushed or fetched.

What an upgrade buys

A WebSocket begins as an ordinary HTTP request that asks to upgrade, and the server that accepts the upgrade turns a request-reply connection into a persistent, two-way channel. What that buys you is the ability to push data from the server to the client whenever something changes, instead of either side having to ask and wait for an answer. The semantic shift matters: the connection now carries many messages both directions over its lifetime, so a small chat, live ticker or board keeps a single low-latency pipe alive instead of rebuilding the request chain for every update.

Polling is still a valid baseline

Polling remains a valid baseline, and it is worth keeping honestly in mind before adopting WebSocket. If events arrive rarely and late is fine, a normal interval request that reads the current value is simpler, cache-friendlier and far easier to operate than a long-lived socket. Every message you send over the socket has overhead, every connection consumes resources, and proxies and load balancers need to allow both the upgrade and the longer idle time. Choosing WebSocket should come from a real need for server-initiated, low-latency push, not from a preference for the newer API.

Keep the session healthy

Once you hold a session open, you have to keep it healthy. Add heartbeat or ping messages so idle connections do not get silently cut by a middlebox, re-establish on a lost socket with an exponential standoff, and treat a broken session the same way you would a closed page, by cleaning up server state and unsubscribing. The connection is also code that must authentic the user at handshake time, because after the upgrade there is no new request to prove identity. Running a channel responsibly means planning for reconnects, heartbeats, cleanup and identity long before you need them.

When it breaks

An idle WebSocket is the first thing a proxy kills. Proxies routinely close connections quiet for sixty seconds, while both endpoints still believe the session is open. The failure is silent: no close frame arrives, so the client queues messages into a dead socket the server still lists as subscribed. Without heartbeats there is no way to tell a quiet peer from a dead one, and the server slowly accumulates ghost connections that waste file descriptors and absorb broadcasts nobody consumes.

Scaling out exposes the second failure. Session state lives on the node that accepted the handshake: its subscription set, its authentication result, its queue. If a reconnect lands on a different node because stickiness was lost, the client must resubscribe from scratch or it silently misses messages it assumes survived. Identity shares the trap, because the token was checked once at handshake time and never travels again.

Recovery can finish the outage. When the server restarts or a proxy drops thousands of sockets at once, every client redials immediately, and the burst becomes a reconnect storm that hammers authentication and subscription paths when capacity is lowest. The fix is deliberately boring: ping faster than the proxy idle timeout, evict peers that miss two pongs, re-authenticate on every handshake, keep subscriptions rebuildable, and reconnect with exponential backoff plus jitter.

Check it quickly

Save the following as ws_session_probe.py and run python3 ws_session_probe.py. It uses only the standard library and binds only loopback. A loopback http.server checks a raw upgrade, and a socketpair models the heartbeat deadline.

"""Probe WebSocket session semantics with stdlib only: handshake + heartbeat."""
import base64
import hashlib
import socket
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
# RFC 6455 section 1.2 example key and its expected accept value.
KEY = "dGhlIHNhbXBsZSBub25jZQ=="
EXPECTED_ACCEPT = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="


def compute_accept(key: str) -> str:
    return base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self):
        key = self.headers.get("Sec-WebSocket-Key")
        upgrade = (self.headers.get("Upgrade") or "").lower()
        connection = (self.headers.get("Connection") or "").lower()
        ok = (
            self.path == "/chat"
            and key is not None
            and upgrade == "websocket"
            and "upgrade" in connection
        )
        if not ok:
            body = b"expected websocket upgrade"
            self.send_response(400)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        accept = compute_accept(key.strip())
        self.send_response(101, "Switching Protocols")
        self.send_header("Upgrade", "websocket")
        self.send_header("Connection", "Upgrade")
        self.send_header("Sec-WebSocket-Accept", accept)
        self.send_header("Content-Length", "0")
        self.end_headers()

    def log_message(self, *args):
        pass


def read_http_response(sock: socket.socket) -> str:
    data = b""
    sock.settimeout(5)
    while b"\r\n\r\n" not in data:
        chunk = sock.recv(4096)
        if not chunk:
            break
        data += chunk
    return data.decode("latin-1")


def main() -> None:
    # 1. The accept derivation itself must match the RFC 6455 test vector.
    assert compute_accept(KEY) == EXPECTED_ACCEPT, "accept mismatch"
    print(f"accept vector ok: {EXPECTED_ACCEPT}")

    # 2. A real loopback handshake: raw request, assert 101 + accept echo.
    server = HTTPServer(("127.0.0.1", 0), Handler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        port = server.server_address[1]
        with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
            sock.sendall(
                (
                    "GET /chat HTTP/1.1\r\n"
                    "Host: 127.0.0.1\r\n"
                    "Upgrade: websocket\r\n"
                    "Connection: Upgrade\r\n"
                    f"Sec-WebSocket-Key: {KEY}\r\n"
                    "Sec-WebSocket-Version: 13\r\n"
                    "\r\n"
                ).encode()
            )
            head = read_http_response(sock)
        assert "101" in head.splitlines()[0], head
        assert f"Sec-WebSocket-Accept: {EXPECTED_ACCEPT}" in head, head
        print("handshake ok: 101 + Sec-WebSocket-Accept matches key")

        # 3. A wrong path is not a session: it must stay plain HTTP.
        with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
            sock.sendall(b"GET /page HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
            head = read_http_response(sock)
        assert "400" in head.splitlines()[0], head
        print("non-upgrade ok: plain GET gets 400, no session created")

        # 4. Heartbeat semantics over a socketpair: a pong in time keeps the
        #    session, silence past the deadline marks a ghost connection.
        alive, peer = socket.socketpair()
        with alive, peer:
            alive.settimeout(2)
            peer.settimeout(2)
            alive.sendall(b"ping-1")
            assert peer.recv(1024) == b"ping-1"
            peer.sendall(b"pong-1")
            assert alive.recv(1024) == b"pong-1"
            print("heartbeat ok: ping/pong round-trip keeps session alive")
            peer.settimeout(0.2)
            try:
                peer.recv(1024)
                raise AssertionError("expected silence, got data")
            except socket.timeout:
                print("ghost detection ok: missed pong past deadline = stale session")
    finally:
        server.shutdown()
        server.server_close()
    print("PASS: handshake, rejection, heartbeat, and ghost detection hold")


if __name__ == "__main__":
    main()

The script asserts four session properties. The accept derivation matches the RFC 6455 test vector; a well-formed upgrade returns 101 with the matching Sec-WebSocket-Accept, while a plain GET elsewhere gets 400 and creates no session. A ping and pong round-trip keeps the session alive, and silence past the deadline counts as a stale ghost; a passing run ends with PASS and any failure raises instead. The assertions passed with Python 3.14 on macOS. The fixture covers handshake and heartbeat logic only, not proxy idle timeouts, TLS termination, or browser reconnect behavior, so repeat them on the real path.

Further reading

Sources