Learn WebSocket in a Single Post: Complete Tutorial From Handshake and Frames to Rooms, Scaling, and Production

WebSocket is the protocol that turns HTTP’s request/response model into a persistent, full-duplex channel — the server can push data to the client at any time, and the client can send to the server, all over one TCP connection. It’s what powers chat apps, live dashboards, multiplayer games, collaborative editors, and real-time notifications. This single post covers the whole protocol in five stages, with hand-drawn diagrams and runnable code.

Learning Roadmap

WebSocket + Real-Time Web Roadmap

The roadmap moves from the handshake (Stage 1), through the frame format (Stage 2), server architecture (Stage 3), scaling (Stage 4), and production concerns (Stage 5). The REST API tutorial and Nginx tutorial are companions — WebSocket is the real-time layer on top of the same HTTP infrastructure.


Stage 1 — The Handshake: HTTP Upgrade → WebSocket

How it starts as HTTP

A WebSocket connection begins as an HTTP request. The client sends a normal HTTP GET with special upgrade headers; if the server agrees, it responds with 101 Switching Protocols and the connection becomes a WebSocket.

The Handshake: HTTP Upgrade -> WebSocket

Client → Server (HTTP request):
  GET /ws HTTP/1.1
  Upgrade: websocket
  Connection: Upgrade
  Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
  Sec-WebSocket-Version: 13

Server → Client (HTTP response):
  HTTP/1.1 101 Switching Protocols
  Upgrade: websocket
  Connection: Upgrade
  Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

After the 101, the TCP connection is no longer HTTP — it’s a WebSocket. Both sides can send frames at any time.

The Sec-WebSocket-Accept calculation

The server must echo back a Sec-WebSocket-Accept header = base64(SHA1(client_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")). The magic GUID is a constant defined in the RFC — it exists to confirm the server actually speaks WebSocket (not just echoing the key). This prevents a non-WebSocket HTTP server from accidentally “upgrading”.

// Node.js ws library handles this automatically
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
  ws.on("message", (data) => console.log("received:", data.toString()));
  ws.send("welcome");
});

Pitfall: Nginx (and other reverse proxies) must be configured for WebSocket — without proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade";, the upgrade handshake is stripped and the connection fails. See the Nginx tutorial for the full config.


Stage 2 — Frames: Opcodes, Ping/Pong, Close

The frame format

WebSocket Frames: Opcodes, Ping/Pong, Close

After the handshake, all communication is in frames — binary packets with a small header:

Field What it means
opcode (4 bits) frame type (text, binary, ping, pong, close, continuation)
FIN (1 bit) is this the last fragment?
mask (1 bit) is the payload XOR-masked? (client→server must be masked; server→client must not)
payload length 7/16/64 bits (variable-length encoding)
masking key 4 bytes (if masked)
payload the actual data

Opcodes

Opcode Type Use
0x1 Text UTF-8 text (chat, JSON)
0x2 Binary raw bytes (images, protobuf, files)
0x8 Close close the connection (with optional code + reason)
0x9 Ping keep-alive probe (“are you there?”)
0xA Pong reply to ping (“yes”)
0x0 Continuation fragment of a multi-frame message

Ping/pong — liveness

The server (or client) sends a ping frame; the other side must respond with a pong. If no pong arrives within a timeout, the connection is considered dead and closed. This detects half-open connections (the TCP connection is alive but the peer is unresponsive — e.g. a laptop that went to sleep behind a NAT that dropped the mapping).

Close codes

Code Meaning
1000 normal closure
1001 endpoint going away (page unload)
1011 server encountered an error
4000-4999 application-defined (your app’s custom codes)

Masking

Client-to-server frames are masked (XOR’d with a 4-byte key in the frame). This is a browser security measure — it prevents a malicious script from sending crafted bytes that look like a different protocol to a non-WebSocket server (cache poisoning). The ws library handles this automatically; you never see it in application code.

Pitfall: A slow client that doesn’t read from the socket fills the server’s send buffer. Without backpressure handling (checking ws.bufferedAmount and pausing sends), one slow client can OOM the server. Always check the buffer before sending, or use a library that handles backpressure (Socket.IO does).


Stage 3 — Server Architecture: Rooms, Broadcast, Backpressure

The room model

The most common WebSocket server pattern is rooms: clients join named rooms; the server broadcasts to a room; everyone in the room receives it.

Server Architecture: Rooms, Broadcast, Scaling

const rooms = new Map();  // room_name -> Set<WebSocket>

function join(ws, room) {
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(ws);
  ws.room = room;
}

function broadcast(room, message) {
  const members = rooms.get(room);
  if (!members) return;
  for (const ws of members) {
    if (ws.readyState === ws.OPEN) ws.send(message);
  }
}

wss.on("connection", (ws) => {
  ws.on("message", (data) => {
    const msg = JSON.parse(data);
    if (msg.type === "join") join(ws, msg.room);
    if (msg.type === "chat") broadcast(ws.room, msg.text);
  });
  ws.on("close", () => rooms.get(ws.room)?.delete(ws));
});

Backpressure

A slow client fills the server’s send buffer. Backpressure = detect this and stop sending to that client:

function safeSend(ws, message) {
  if (ws.bufferedAmount > 1024 * 1024) {  // 1 MB backed up
    ws.close(1011, "slow consumer");       // disconnect the slow client
    return;
  }
  ws.send(message);
}

Or use a message queue per client with a drain mechanism. Socket.IO handles this automatically; raw ws requires manual care.

Pitfall: Without backpressure, a single slow client can exhaust server memory (every send queues data in the kernel buffer). Monitor ws.bufferedAmount and close slow consumers.


Stage 4 — Scaling: Sticky Sessions + Redis Pub/Sub

The cross-server problem

If you have 2 WebSocket servers (behind a load balancer) and Client A is on Server 1, Client B is on Server 2, Server 1 can’t deliver a message to Client B — it doesn’t have B’s connection. The solution: Redis pub/sub as a shared message bus.

Client A (Server 1) sends "hello" to room "chat"
  → Server 1 broadcasts to its local "chat" members
  → Server 1 also PUBLISHES "chat:hello" to Redis
  → Server 2 SUBSCRIBES to "chat" → receives "hello" → delivers to Client B
const redis = require("redis");
const pub = redis.createClient(); const sub = redis.createClient();
await sub.subscribe("chat");
sub.on("message", (channel, msg) => broadcast("chat", msg));  // deliver to local clients

// when a client sends:
ws.on("message", (data) => {
  broadcast(ws.room, data);          // local
  pub.publish(ws.room, data);        // cross-server via Redis
});

Sticky sessions

The load balancer must use sticky sessions (IP hash or a cookie) so a client always reaches the same server — otherwise the connection resets on each reconnect. Nginx: ip_hash;. An ALB: use a cookie-based stickiness policy.

Socket.IO — the batteries-included library

Socket.IO (Node.js) and its equivalents handle rooms, broadcast, reconnection, ping/pong, and Redis-based scaling automatically:

const io = require("socket.io")(3000);
io.adapter(require("@socket.io/redis-adapter")({ pubClient, subClient }));

io.on("connection", (socket) => {
  socket.join("chat");
  io.to("chat").emit("message", "hello");  // broadcasts to all servers' "chat" rooms
});

Stage 5 — Production: TLS, Reconnect, vs Alternatives

wss:// — WebSocket over TLS

ws://  = plain WebSocket (insecure, port 80)
wss:// = WebSocket over TLS (port 443)

Always use wss:// in production (same as HTTPS vs HTTP). The TLS handshake happens before the WebSocket upgrade.

Ping interval + reconnect

The server should send periodic pings (e.g. every 30s); if no pong, close the connection. The client should auto-reconnect with exponential backoff (Socket.IO does this; raw ws requires manual handling):

function connect() {
  const ws = new WebSocket("wss://example.com/ws");
  ws.onopen = () => console.log("connected");
  ws.onclose = () => setTimeout(connect, Math.random() * 5000 + 1000);  // backoff
  ws.onmessage = (e) => console.log(e.data);
}
connect();

WebSocket vs SSE vs Long-Polling vs HTTP/2 Streams

WebSocket vs SSE vs Long-Polling vs HTTP/2 Streams

  Direction Binary Auto-reconnect Use
WebSocket full-duplex manual chat, games, real-time
SSE (Server-Sent Events) server→client only text ✅ built-in notifications, live feed
Long-polling request/response no manual legacy fallback
HTTP/2 streams request + response streaming no progressive JSON

Rule of thumb:

  • Need server→client only (push notifications, live feed)? SSE is simpler — it’s just HTTP, auto-reconnects, and works through any proxy.
  • Need full-duplex (client also sends)? WebSocket.
  • Long-polling is the legacy fallback when nothing else works (old browsers, restrictive proxies).
  • HTTP/2 streaming is an option for one-way streaming within HTTP/2 (progressive JSON, server push).

Pitfall: Many developers reach for WebSocket when SSE would be simpler and better. If your use case is one-directional (server pushes to client — notifications, stock prices, logs), SSE is the right choice: one HTTP endpoint, auto-reconnect, works through every proxy, no handshake complexity.


Quick-Start Checklist

  1. Install ws (Node.js) — npm install ws.
  2. Create a WebSocket servernew WebSocketServer({ port: 8080 }).
  3. Connect from a browsernew WebSocket("ws://localhost:8080").
  4. Send a messagews.send("hello") on both sides.
  5. Add roomsMap<room_name, Set<WebSocket>> + join/broadcast.
  6. Handle close — remove the client from its room.
  7. Add backpressure — check ws.bufferedAmount before sending.
  8. Configure Nginxproxy_set_header Upgrade/Connection for the upgrade.
  9. Scale with Redis — pub/sub for cross-server broadcast.
  10. Use wss:// in production — TLS before upgrade.

Common Pitfalls

  • Nginx without upgrade headersUpgrade + Connection: upgrade required, else the handshake fails.
  • No backpressure — a slow client fills the server buffer → OOM. Check bufferedAmount.
  • No ping/pong — half-open connections accumulate (client went to sleep). Ping every 30s; close on timeout.
  • No reconnect on client — a dropped connection is silent. Implement exponential-backoff reconnect.
  • Using WebSocket for one-directional data — SSE is simpler for push-only. Don’t over-engineer.
  • No sticky sessions behind a load balancer — client connects to a different server each time → lost state. Use IP hash or cookie.
  • Memory leak on close — always remove the client from its room on close/error.
  • Broadcasting to everyone — broadcasting to all connections on every message doesn’t scale; use rooms/channels.

Further Reading

WebSocket is the real-time layer on the web stack — these PyShine tutorials connect to it:


WebSocket’s value is the persistent, full-duplex channel — once the handshake is done, the server pushes whenever it wants, the client sends whenever it wants, all over one TCP connection with no per-message HTTP overhead. The five stages here — handshake, frames, server architecture, scaling, production — cover everything from a one-room chat to a multi-server, Redis-backed, backpressure-aware, wss:// production real-time system. The two habits that pay off: check for backpressure before every send (one slow client can OOM the server), and choose SSE for one-directional push (don’t reach for WebSocket when Server-Sent Events is simpler). Open a browser console, new WebSocket("ws://localhost:8080"), send a message, and watch the server receive it instantly — once you’ve felt the full-duplex channel, the model clicks.

Watch PyShine on YouTube

Contents