Introduction
WebSocket (RFC 6455) provides full-duplex communication over a single TCP connection, unlike HTTP’s request-response model. After an HTTP Upgrade handshake, the connection switches to WebSocket framing with minimal overhead — no HTTP headers on each message. This makes it the standard for real-time applications: chat, live dashboards, financial tickers, collaborative editing, and online gaming.
This guide covers the WebSocket handshake with a Mermaid sequence diagram, JavaScript client API with explained event handlers, Python async server with connection management and broadcasting, Nginx reverse proxy for WSS (WebSocket Secure), and production considerations including subprotocol negotiation, heartbeat/ping-pong, and auto-reconnection.
WebSocket Handshake
The handshake starts as an HTTP request with an Upgrade header and switches protocols:
sequenceDiagram
participant Client
participant Server
Note over Client,Server: HTTP Upgrade Handshake
Client->>Server: GET /chat HTTP/1.1
Client->>Server: Host: server.example.com
Client->>Server: Upgrade: websocket
Client->>Server: Connection: Upgrade
Client->>Server: Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Client->>Server: Sec-WebSocket-Version: 13
Server-->>Client: HTTP/1.1 101 Switching Protocols
Server-->>Client: Upgrade: websocket
Server-->>Client: Connection: Upgrade
Server-->>Client: Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Note over Client,Server: Connection upgraded to WebSocket
Note over Client,Server: Full-duplex communication begins
The Sec-WebSocket-Key is a random 16-byte value base64-encoded. The server concatenates it with a fixed GUID (258EAFA5-E914-47DA-95CA-C5AB0DC85B11), computes SHA-1, and returns the base64 result as Sec-WebSocket-Accept. This confirms the server actually understands WebSocket, not just any HTTP server.
JavaScript Client
The browser’s WebSocket API provides event-driven connection management. The key events are onopen (connection established), onmessage (data received), onerror (connection error), and onclose (disconnection with code and reason):
// Client-side WebSocket with auto-reconnection
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.reconnectInterval = options.reconnectInterval || 3000;
this.maxRetries = options.maxRetries || 10;
this.retries = 0;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.retries = 0;
// Join a room immediately after connecting
this.send(JSON.stringify({ type: 'join', room: 'lobby' }));
};
this.ws.onmessage = (event) => {
// event.data contains the message (string or Blob)
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onclose = (event) => {
// event.code: 1000=normal, 1006=abnormal disconnect
// event.reason: human-readable reason string
console.log(`Disconnected: code=${event.code} reason=${event.reason}`);
if (this.retries < this.maxRetries) {
this.retries++;
setTimeout(() => this.connect(), this.reconnectInterval);
}
};
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
}
handleMessage(data) {
// Dispatch by message type
switch (data.type) {
case 'message': console.log('Message:', data.payload); break;
case 'presence': this.updateUserList(data.users); break;
case 'pong': /* heartbeat response */ break;
}
}
close() {
this.maxRetries = 0; // prevent reconnect
this.ws.close(1000, 'Client closing');
}
}
const ws = new ReconnectingWebSocket('wss://chat.example.com');
Python Server with websockets Library
The Python websockets library provides an async server based on asyncio. The handler coroutine manages a single client connection, receiving messages in a loop and broadcasting to other connected clients:
import asyncio
import json
import websockets
# Track all connected clients for broadcasting
connected_clients = set()
async def chat_handler(websocket):
"""Handle a single WebSocket connection.
Registers the client in the connected set, then loops
receiving messages until the connection closes.
"""
connected_clients.add(websocket)
try:
async for message in websocket:
data = json.loads(message)
if data['type'] == 'message':
# Broadcast message to all connected clients
await broadcast(json.dumps({
'type': 'message',
'sender': id(websocket),
'payload': data['payload']
}))
elif data['type'] == 'ping':
# Respond to heartbeat ping
await websocket.send(json.dumps({'type': 'pong'}))
except websockets.exceptions.ConnectionClosed:
print(f"Client {id(websocket)} disconnected")
finally:
connected_clients.discard(websocket)
async def broadcast(message: str):
"""Send a message to all connected clients.
Uses asyncio.gather to send to all clients concurrently.
Failed sends are logged but don't block other clients.
"""
if not connected_clients:
return
await asyncio.gather(
*(client.send(message) for client in connected_clients),
return_exceptions=True
)
async def main():
async with websockets.serve(chat_handler, "0.0.0.0", 8765):
print("WebSocket server running on ws://0.0.0.0:8765")
await asyncio.Future() # Run forever
asyncio.run(main())
Nginx Reverse Proxy for WSS
WebSocket connections need special proxy configuration because standard HTTP proxies don’t understand the Upgrade mechanism. Nginx requires explicit Upgrade and Connection headers:
# /etc/nginx/sites-available/websocket
server {
listen 443 ssl;
server_name ws.example.com;
ssl_certificate /etc/letsencrypt/live/ws.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ws.example.com/privkey.pem;
location /ws/ {
proxy_pass http://localhost:8765;
proxy_http_version 1.1;
# Critical: these headers enable WebSocket protocol upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Timeout: WebSocket connections can be long-lived
proxy_read_timeout 86400s; # 24 hours
}
}
WebSocket Frame Format
WebSocket frames use a compact binary format with minimal overhead:
| Bit 0 (FIN) | Bits 1-3 (RSV) | Bits 4-7 (Opcode) | Bit 8 (MASK) | Bits 9-15 (Payload Len) | Extended Length (optional) | Masking Key (optional) | Payload Data |
|---|---|---|---|---|---|---|---|
| 1=final | Reserved | 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong | 1=masked (client→server) | 7-126-127 bytes | 16 or 64 bits if len >125 | 4 bytes if MASK=1 | Variable |
Clients MUST mask all frames sent to the server (for cache poisoning protection). Servers MUST NOT mask frames sent to clients. This asymmetry is enforced by the protocol.
Subprotocol Negotiation
The client can request a subprotocol during the handshake to specify the message format:
// Client requests a subprotocol
const ws = new WebSocket('wss://api.example.com', ['json-v2', 'json-v1']);
// Server selects the first one it supports: 'json-v2'
# Server selects from requested subprotocols
async def handler(websocket):
subprotocol = websocket.request_headers.get('Sec-WebSocket-Protocol', '')
# Server can choose based on client request
Resources
- RFC 6455 — The WebSocket Protocol
- Python websockets Library — Async server/client implementation
- MDN WebSocket API — Browser JavaScript reference
- Nginx WebSocket Proxying — WSS reverse proxy configuration
- WebSocket Subprotocols — IANA registry
Comments