Tech Guides
01

Quick Reference

The essential API and setup commands at a glance. Copy-paste these to get a WebSocket connection running in seconds.

Browser API (Client)

// Connect
const ws = new WebSocket('wss://example.com/ws');

// Send
ws.send('Hello server');
ws.send(JSON.stringify({ type: 'chat', msg: 'hi' }));

// Receive
ws.onmessage = (e) => console.log(e.data);

// Close
ws.close(1000, 'Normal closure');

Node.js Server (ws)

npm install ws
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    console.log('received: %s', data);
    ws.send('echo: ' + data);
  });
  ws.send('Connected to server');
});

Python Server (websockets)

pip install websockets
import asyncio
import websockets

async def handler(websocket):
    async for message in websocket:
        await websocket.send(f"echo: {message}")

async def main():
    async with websockets.serve(handler, "localhost", 8080):
        await asyncio.Future()  # run forever

asyncio.run(main())

Quick Commands

wscat (CLI client)
npx wscat -c wss://echo.websocket.org

Interactive WebSocket client for testing

websocat (Rust)
websocat wss://echo.websocket.org

Swiss-army tool for WebSocket pipes

curl (v7.86+)
curl --include \ --header "Upgrade: websocket" \ --header "Connection: Upgrade" \ --header "Sec-WebSocket-Key: x3JJHMbD..." \ --header "Sec-WebSocket-Version: 13" \ http://localhost:8080/ws

Manual HTTP upgrade inspection

DevTools
F12 > Network > WS filter

Inspect frames, timing, close codes

02

Protocol Deep Dive

WebSocket (RFC 6455) starts as an HTTP/1.1 upgrade, then switches to a binary framing protocol over the same TCP connection.

HTTP Upgrade Handshake

The connection begins with a standard HTTP request containing upgrade headers. The server responds with 101 Switching Protocols.

// Client Request
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat, superchat
Origin: https://example.com
// Server Response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
The Sec-WebSocket-Accept value is a SHA-1 hash of the client key concatenated with the magic GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, then base64-encoded. This prevents caching proxies from replaying old responses.

Frame Format

After the handshake, data flows as frames. Each frame has a compact binary header:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |   Extended payload length     |
|I|S|S|S|  (4)  |A|     (7)     |          (16/64 bits)         |
|N|V|V|V|       |S|             |   (if payload len == 126/127) |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+-------------------------------+
|    Masking-key (0 or 4 bytes) |                               |
+-------------------------------+-------------------------------+
|                     Payload Data                              |
+---------------------------------------------------------------+
FieldBitsDescription
FIN1Final fragment in a message (1 = last)
RSV1-33Reserved for extensions (e.g., permessage-deflate uses RSV1)
Opcode4Frame type (see table below)
MASK1Whether payload is masked (client-to-server MUST mask)
Payload length7+0-125 inline, 126 = next 2 bytes, 127 = next 8 bytes
Masking key0/32XOR key for payload (only if MASK=1)

Opcodes

OpcodeHexTypeDescription
0x00ContinuationFragment of a multi-frame message
0x11TextUTF-8 text frame
0x22BinaryBinary data frame
0x88CloseConnection close (may include status code + reason)
0x99PingHeartbeat request (must respond with Pong)
0xA10PongHeartbeat response (echoes ping payload)

Client Masking

All frames from client to server MUST be masked. The 4-byte masking key is randomly generated per frame. The payload is XORed byte-by-byte:

// Masking algorithm (RFC 6455 Section 5.3)
maskedByte[i] = originalByte[i] XOR maskingKey[i % 4]

// Example: mask key = [0x37, 0xfa, 0x21, 0x3d]
// Payload: "Hello" = [0x48, 0x65, 0x6c, 0x6c, 0x6f]
// Masked:  [0x48^0x37, 0x65^0xfa, 0x6c^0x21, 0x6c^0x3d, 0x6f^0x37]
//        = [0x7f, 0x9f, 0x4d, 0x51, 0x58]
Masking exists to prevent cache-poisoning attacks on intermediary proxies, not for encryption. Always use wss:// (TLS) for actual security.

Close Codes

CodeNameMeaning
1000Normal ClosureClean shutdown
1001Going AwayServer shutting down or browser navigating
1002Protocol ErrorEndpoint received a malformed frame
1003Unsupported DataReceived data type it cannot handle
1006Abnormal ClosureNo close frame received (connection dropped)
1007Invalid PayloadData within a message is inconsistent
1008Policy ViolationGeneric policy violation
1009Message Too BigMessage exceeds size limit
1011Internal ErrorUnexpected server condition
1012Service RestartServer is restarting
1013Try Again LaterTemporary server condition
1015TLS Handshake FailTLS handshake failure (never sent in close frame)
4000-4999Private UseApplication-defined codes
03

Browser WebSocket API

The browser's native WebSocket object. Zero dependencies, supported in all modern browsers since 2011.

Constructor

// Basic connection
const ws = new WebSocket('wss://example.com/ws');

// With sub-protocol negotiation
const ws = new WebSocket('wss://example.com/ws', ['graphql-ws', 'chat']);

// The selected protocol is available after open
ws.onopen = () => console.log(ws.protocol); // "graphql-ws"
PropertyTypeDescription
ws.urlstringThe URL passed to the constructor
ws.readyStatenumber0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
ws.protocolstringServer-selected sub-protocol
ws.bufferedAmountnumberBytes queued but not yet sent
ws.binaryTypestring"blob" (default) or "arraybuffer"
ws.extensionsstringNegotiated extensions (e.g., "permessage-deflate")

Event Handlers

const ws = new WebSocket('wss://example.com/ws');

// Connection opened
ws.onopen = (event) => {
  console.log('Connected');
  ws.send('Hello!');
};

// Message received
ws.onmessage = (event) => {
  // event.data is string for text frames,
  // Blob or ArrayBuffer for binary frames
  const data = JSON.parse(event.data);
  console.log('Received:', data);
};

// Connection closed
ws.onclose = (event) => {
  console.log(`Closed: code=${event.code} reason="${event.reason}"`);
  console.log('Was clean:', event.wasClean);
};

// Error occurred (always followed by onclose)
ws.onerror = (event) => {
  console.error('WebSocket error:', event);
  // Note: error details are intentionally hidden
  // for security (same-origin policy)
};
You can also use addEventListener for multiple handlers: ws.addEventListener('message', handler1)

Sending Data

// String (text frame, opcode 0x1)
ws.send('Hello, server!');

// JSON (most common pattern)
ws.send(JSON.stringify({
  type: 'chat',
  room: 'general',
  message: 'Hello everyone'
}));

// ArrayBuffer (binary frame, opcode 0x2)
const buffer = new ArrayBuffer(4);
new DataView(buffer).setUint32(0, 42);
ws.send(buffer);

// Blob (binary frame)
const blob = new Blob(['binary data'], { type: 'application/octet-stream' });
ws.send(blob);

// TypedArray (binary frame)
const bytes = new Uint8Array([0x01, 0x02, 0x03]);
ws.send(bytes);

// Check if send queue is backed up
if (ws.bufferedAmount === 0) {
  ws.send(largePayload);
} else {
  console.warn('Send buffer not empty, waiting...');
}

Closing the Connection

// Clean close
ws.close();

// Close with code and reason
ws.close(1000, 'User logged out');

// Close with application-specific code
ws.close(4001, 'Session expired');

// The close handshake is async; listen for onclose
ws.onclose = (e) => {
  if (e.wasClean) {
    console.log('Clean disconnect');
  } else {
    console.log('Connection lost (code:', e.code, ')');
  }
};
04

Server Implementations

Production-ready WebSocket servers across popular languages. Each handles the upgrade handshake, frame parsing, and connection management.

Node.js — ws

The most popular Node.js WebSocket library. Lightweight, fast, and spec-compliant.

import { WebSocketServer } from 'ws';
import { createServer } from 'http';

const server = createServer();
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;
  console.log(`Client connected from ${ip}`);

  ws.on('message', (data, isBinary) => {
    // Broadcast to all other clients
    wss.clients.forEach((client) => {
      if (client !== ws && client.readyState === 1) {
        client.send(data, { binary: isBinary });
      }
    });
  });

  ws.on('close', (code, reason) => {
    console.log(`Disconnected: ${code} ${reason}`);
  });

  // Heartbeat with ping/pong
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
});

// Detect broken connections
const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('close', () => clearInterval(interval));

server.listen(8080);

Python — websockets

Async-first Python library built on asyncio. Clean, modern API.

import asyncio
import websockets
import json

connected = set()

async def handler(websocket):
    connected.add(websocket)
    try:
        async for message in websocket:
            data = json.loads(message)
            # Broadcast to all connected clients
            websockets.broadcast(connected, json.dumps({
                "user": data.get("user", "anonymous"),
                "message": data["message"]
            }))
    finally:
        connected.discard(websocket)

async def main():
    async with websockets.serve(
        handler,
        "0.0.0.0",
        8080,
        ping_interval=20,    # Send ping every 20s
        ping_timeout=20,     # Wait 20s for pong
        max_size=2**20,      # 1MB max message size
        compression="deflate"
    ):
        await asyncio.Future()

asyncio.run(main())

Go — gorilla/websocket

The standard Go WebSocket library. High performance, production-tested. Note: gorilla/websocket is in maintenance mode; nhooyr.io/websocket (or coder/websocket) is the modern alternative.

package main

import (
    "log"
    "net/http"
    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin: func(r *http.Request) bool {
        // In production, validate origin
        return true
    },
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println("Upgrade error:", err)
        return
    }
    defer conn.Close()

    // Set read deadline for heartbeat
    conn.SetReadDeadline(time.Now().Add(60 * time.Second))
    conn.SetPongHandler(func(string) error {
        conn.SetReadDeadline(time.Now().Add(60 * time.Second))
        return nil
    })

    for {
        msgType, msg, err := conn.ReadMessage()
        if err != nil {
            if websocket.IsUnexpectedCloseError(err,
                websocket.CloseGoingAway,
                websocket.CloseNormalClosure) {
                log.Printf("Error: %v", err)
            }
            break
        }
        // Echo back
        if err := conn.WriteMessage(msgType, msg); err != nil {
            break
        }
    }
}

func main() {
    http.HandleFunc("/ws", wsHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Comparison

Featurews (Node)websockets (Python)gorilla (Go)
Async modelEvent loopasyncioGoroutines
Compressionpermessage-deflatepermessage-deflateManual
Max message sizeConfigurableConfigurableConfigurable
Auto ping/pongManualBuilt-inManual
HTTP integrationNative httpStandalone/ASGInet/http
Connections per core~10K~5K~100K+
05

Lifecycle & Reconnection

Connections drop. Networks fail. Servers restart. A robust WebSocket client must handle reconnection gracefully.

Connection States

// WebSocket.readyState values
WebSocket.CONNECTING  // 0 - Socket created, not yet open
WebSocket.OPEN        // 1 - Connection established
WebSocket.CLOSING     // 2 - close() called, handshake in progress
WebSocket.CLOSED      // 3 - Connection closed or failed to open

// State flow:
// CONNECTING -> OPEN -> CLOSING -> CLOSED
// CONNECTING -> CLOSED (if connection fails)

Exponential Backoff Reconnection

class ReconnectingWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.maxRetries = options.maxRetries ?? Infinity;
    this.baseDelay = options.baseDelay ?? 1000;
    this.maxDelay = options.maxDelay ?? 30000;
    this.retryCount = 0;
    this.handlers = { message: [], open: [], close: [] };
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.retryCount = 0; // Reset on success
      this.handlers.open.forEach(fn => fn());
    };

    this.ws.onmessage = (e) => {
      this.handlers.message.forEach(fn => fn(e));
    };

    this.ws.onclose = (e) => {
      this.handlers.close.forEach(fn => fn(e));
      if (!e.wasClean && this.retryCount < this.maxRetries) {
        this.scheduleReconnect();
      }
    };

    this.ws.onerror = () => {}; // onclose will fire after
  }

  scheduleReconnect() {
    // Exponential backoff with jitter
    const delay = Math.min(
      this.baseDelay * Math.pow(2, this.retryCount)
        + Math.random() * 1000,
      this.maxDelay
    );
    console.log(`Reconnecting in ${Math.round(delay)}ms...`);
    this.retryCount++;
    setTimeout(() => this.connect(), delay);
  }

  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(data);
    }
  }

  on(event, fn) { this.handlers[event].push(fn); }

  close() {
    this.maxRetries = 0; // Prevent reconnection
    this.ws.close(1000, 'Client closing');
  }
}

// Usage
const ws = new ReconnectingWebSocket('wss://api.example.com/ws', {
  maxRetries: 10,
  baseDelay: 1000,
  maxDelay: 30000
});

Heartbeat / Keep-Alive

// Client-side heartbeat
class HeartbeatWebSocket {
  constructor(url) {
    this.url = url;
    this.heartbeatInterval = 25000; // 25s
    this.heartbeatTimeout = 35000;  // 35s to detect dead
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.startHeartbeat();
    };

    this.ws.onmessage = (e) => {
      this.resetTimeout(); // Any message resets the timer
      if (e.data === 'pong') return; // Heartbeat response
      // Handle actual messages...
    };

    this.ws.onclose = () => {
      this.stopHeartbeat();
    };
  }

  startHeartbeat() {
    this.pingTimer = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send('ping');
      }
    }, this.heartbeatInterval);
    this.resetTimeout();
  }

  resetTimeout() {
    clearTimeout(this.deadTimer);
    this.deadTimer = setTimeout(() => {
      console.warn('Connection appears dead, closing');
      this.ws.close(4000, 'Heartbeat timeout');
    }, this.heartbeatTimeout);
  }

  stopHeartbeat() {
    clearInterval(this.pingTimer);
    clearTimeout(this.deadTimer);
  }
}
Most server libraries handle WebSocket-level ping/pong automatically. Application-level heartbeats (sending "ping" as a text message) detect not just TCP liveness but also application-layer responsiveness.

Message Queuing During Reconnect

class QueuedWebSocket extends ReconnectingWebSocket {
  constructor(url, options) {
    super(url, options);
    this.queue = [];
  }

  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(data);
    } else {
      this.queue.push(data); // Buffer while disconnected
    }
  }

  connect() {
    super.connect();
    const origOnOpen = this.ws.onopen;
    this.ws.onopen = (e) => {
      origOnOpen?.(e);
      // Flush queued messages
      while (this.queue.length > 0) {
        this.ws.send(this.queue.shift());
      }
    };
  }
}
06

Binary Data

WebSockets natively support binary frames alongside text. Use binary for game state, audio/video, file uploads, and custom protocols.

Setting Binary Type

const ws = new WebSocket('wss://example.com/ws');

// Option 1: Receive as ArrayBuffer (recommended for parsing)
ws.binaryType = 'arraybuffer';

// Option 2: Receive as Blob (default, good for file-like data)
ws.binaryType = 'blob';

Sending Binary Data

// ArrayBuffer
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setUint8(0, 0x01);           // Message type
view.setUint8(1, 0x02);           // Sub-type
view.setFloat32(2, 3.14, true);   // Payload (little-endian)
view.setUint16(6, 42, true);      // Sequence number
ws.send(buffer);

// TypedArray (sends underlying buffer)
const positions = new Float32Array([1.0, 2.5, 3.7, 0.0, -1.2, 4.8]);
ws.send(positions);

// Blob from file input
fileInput.onchange = (e) => {
  const file = e.target.files[0];
  ws.send(file); // File extends Blob
};

// Combine header + payload
function sendWithHeader(ws, type, payload) {
  const header = new Uint8Array([type, payload.byteLength >> 8,
                                  payload.byteLength & 0xff]);
  const frame = new Uint8Array(header.length + payload.byteLength);
  frame.set(header);
  frame.set(new Uint8Array(payload), header.length);
  ws.send(frame);
}

Receiving Binary Data

ws.binaryType = 'arraybuffer';

ws.onmessage = (event) => {
  if (typeof event.data === 'string') {
    // Text frame
    handleText(JSON.parse(event.data));
    return;
  }

  // Binary frame (ArrayBuffer)
  const view = new DataView(event.data);
  const msgType = view.getUint8(0);

  switch (msgType) {
    case 0x01: // Position update
      const x = view.getFloat32(1, true);
      const y = view.getFloat32(5, true);
      updatePosition(x, y);
      break;
    case 0x02: // Audio chunk
      const audio = new Uint8Array(event.data, 1);
      playAudioChunk(audio);
      break;
    case 0x03: // Image data
      const blob = new Blob([event.data.slice(1)], { type: 'image/png' });
      displayImage(URL.createObjectURL(blob));
      break;
  }
};

Protocol Buffers over WebSocket

// Using protobuf.js
import protobuf from 'protobufjs';

const root = await protobuf.load('messages.proto');
const ChatMessage = root.lookupType('ChatMessage');

// Send
function sendProto(ws, message) {
  const errMsg = ChatMessage.verify(message);
  if (errMsg) throw Error(errMsg);
  const buffer = ChatMessage.encode(
    ChatMessage.create(message)
  ).finish();
  ws.send(buffer);
}

// Receive
ws.binaryType = 'arraybuffer';
ws.onmessage = (e) => {
  const msg = ChatMessage.decode(new Uint8Array(e.data));
  console.log(msg.user, msg.text, msg.timestamp);
};
Binary protocols reduce bandwidth by 50-90% compared to JSON for structured data. Use them for high-frequency messages (games, IoT, financial data).
07

Authentication Patterns

The browser WebSocket API does not support custom headers. Here are the proven patterns for authenticating WebSocket connections.

Token in Query String

The simplest approach. Pass a JWT or session token as a URL parameter.

// Client
const token = getAuthToken();
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);

// Server (Node.js + ws)
wss.on('connection', (ws, req) => {
  const url = new URL(req.url, 'wss://localhost');
  const token = url.searchParams.get('token');

  try {
    const user = jwt.verify(token, SECRET);
    ws.userId = user.id;
  } catch {
    ws.close(4001, 'Invalid token');
    return;
  }
});
Tokens in URLs may appear in server access logs, proxy logs, and browser history. Use short-lived tokens and rotate after connection is established.

First-Message Authentication

Connect first, then send credentials as the first message. Reject unauthenticated connections after a timeout.

// Client
const ws = new WebSocket('wss://api.example.com/ws');
ws.onopen = () => {
  ws.send(JSON.stringify({
    type: 'auth',
    token: getAuthToken()
  }));
};

// Server
wss.on('connection', (ws) => {
  ws.isAuthenticated = false;

  // Require auth within 5 seconds
  const authTimeout = setTimeout(() => {
    if (!ws.isAuthenticated) {
      ws.close(4002, 'Authentication timeout');
    }
  }, 5000);

  ws.on('message', (data) => {
    const msg = JSON.parse(data);

    if (!ws.isAuthenticated) {
      if (msg.type === 'auth') {
        try {
          const user = jwt.verify(msg.token, SECRET);
          ws.isAuthenticated = true;
          ws.userId = user.id;
          clearTimeout(authTimeout);
          ws.send(JSON.stringify({ type: 'auth_ok' }));
        } catch {
          ws.close(4001, 'Invalid credentials');
        }
      }
      return; // Ignore other messages until authenticated
    }

    // Handle authenticated messages...
    handleMessage(ws, msg);
  });
});

Cookie-Based Authentication

// If the WebSocket server is same-origin, cookies
// are sent automatically with the upgrade request.
const ws = new WebSocket('wss://example.com/ws');
// Browser sends session cookie with the handshake

// Server (Express + express-session + ws)
import session from 'express-session';

const sessionParser = session({
  secret: 'keyboard cat',
  resave: false,
  saveUninitialized: false
});

// Parse the session from the upgrade request
server.on('upgrade', (req, socket, head) => {
  sessionParser(req, {}, () => {
    if (!req.session.userId) {
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
      socket.destroy();
      return;
    }
    wss.handleUpgrade(req, socket, head, (ws) => {
      wss.emit('connection', ws, req);
    });
  });
});

Ticket-Based (One-Time Token)

// 1. Client requests a WebSocket ticket via REST
const res = await fetch('/api/ws-ticket', {
  headers: { 'Authorization': `Bearer ${jwt}` }
});
const { ticket } = await res.json();

// 2. Connect with the one-time ticket
const ws = new WebSocket(`wss://api.example.com/ws?ticket=${ticket}`);

// 3. Server validates and invalidates the ticket
wss.on('connection', async (ws, req) => {
  const ticket = new URL(req.url, 'wss://x').searchParams.get('ticket');
  const userId = await redis.getdel(`ws-ticket:${ticket}`);
  if (!userId) {
    ws.close(4001, 'Invalid or expired ticket');
    return;
  }
  ws.userId = userId;
});
Ticket-based auth is the most secure pattern: the ticket is single-use, short-lived (60s), and never logged. Combine with HTTPS for the REST call.
08

Scaling WebSockets

A single server can handle 10K-1M+ connections. But scaling horizontally across multiple servers requires coordination for broadcasts and state.

The Problem

// Single server: easy broadcast
wss.clients.forEach(client => client.send(msg));

// Multiple servers behind a load balancer?
// Server A has User 1, 2, 3
// Server B has User 4, 5, 6
// How does Server A's broadcast reach User 4?

// Answer: a pub/sub backbone (Redis, NATS, Kafka, etc.)

Sticky Sessions

Load balancers must route a client to the same WebSocket server. Standard round-robin breaks long-lived connections.

# nginx sticky sessions (ip_hash)
upstream websocket_servers {
    ip_hash;
    server ws1.internal:8080;
    server ws2.internal:8080;
    server ws3.internal:8080;
}

server {
    location /ws {
        proxy_pass http://websocket_servers;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 86400s;  # 24h for long-lived WS
        proxy_send_timeout 86400s;
    }
}
IP-based sticky sessions break when clients share NAT (e.g., corporate networks). Use cookie-based or header-based affinity in production.

Redis Pub/Sub Backbone

import Redis from 'ioredis';

const pub = new Redis();
const sub = new Redis();

// Subscribe to broadcast channels
sub.subscribe('chat:general', 'chat:announcements');

sub.on('message', (channel, message) => {
  // Forward to all local WebSocket clients in this channel
  const room = rooms.get(channel);
  if (room) {
    room.forEach(ws => ws.send(message));
  }
});

// When a local client sends a message, publish to Redis
wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    const msg = JSON.parse(data);
    // Publish to Redis so ALL servers see it
    pub.publish(`chat:${msg.room}`, JSON.stringify({
      user: ws.userId,
      text: msg.text,
      timestamp: Date.now()
    }));
  });
});

Socket.IO Rooms & Adapters

// Socket.IO handles scaling concerns out of the box
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const io = new Server(server, {
  cors: { origin: '*' }
});

// Redis adapter for multi-server coordination
const pubClient = createClient({ url: 'redis://redis:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));

io.on('connection', (socket) => {
  // Rooms are automatically synced across servers
  socket.join('room:lobby');

  socket.on('chat', (msg) => {
    // This reaches ALL clients in the room,
    // even on other servers
    io.to('room:lobby').emit('chat', {
      user: socket.userId,
      msg
    });
  });

  // Private message to a specific user
  socket.on('dm', ({ to, msg }) => {
    io.to(`user:${to}`).emit('dm', {
      from: socket.userId,
      msg
    });
  });
});

Scaling Checklist

ConcernSolution
Connection routingSticky sessions (cookie or header-based)
Cross-server broadcastRedis Pub/Sub, NATS, or Kafka
Connection limitsTune OS: ulimit -n, net.core.somaxconn
Memory per connection~10-50KB each; 100K conns ~ 1-5GB RAM
SSL terminationAt load balancer (nginx, HAProxy, AWS ALB)
Graceful shutdownDrain connections with 1013 (Try Again Later)
Horizontal scalingStateless servers + shared pub/sub backbone
09

SSE vs WebSocket

Server-Sent Events (SSE) is the simpler alternative when you only need server-to-client streaming. Here is when to use each.

Feature Comparison

FeatureWebSocketSSE (EventSource)
DirectionFull-duplexServer-to-client only
Protocolws:// / wss://Standard HTTP/HTTPS
Binary dataYes (native)No (text only, base64 workaround)
Auto-reconnectManualBuilt-in
ResumptionManual (last-event-id)Built-in (Last-Event-ID header)
Max connectionsNo browser limit6 per domain (HTTP/1.1)
Proxy-friendlyNeeds Upgrade supportWorks everywhere (plain HTTP)
Compressionpermessage-deflateStandard gzip/br
Browser supportAll modernAll modern (no IE)
ComplexityHigherLower

SSE Quick Example

// Server (Node.js)
app.get('/events', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

  // Send event every 2 seconds
  const id = setInterval(() => {
    res.write(`id: ${Date.now()}\n`);
    res.write(`event: price-update\n`);
    res.write(`data: ${JSON.stringify({ AAPL: 185.42 })}\n\n`);
  }, 2000);

  req.on('close', () => clearInterval(id));
});

// Client (Browser)
const source = new EventSource('/events');

source.addEventListener('price-update', (e) => {
  const data = JSON.parse(e.data);
  console.log('Price:', data.AAPL);
});

// Auto-reconnects on disconnect!
// Sends Last-Event-ID header to resume from where it left off

When to Use Which

Use WebSocket

Chat applications, multiplayer games, collaborative editing, real-time trading, IoT dashboards, binary streaming, two-way communication

Use SSE

News feeds, stock tickers, notification streams, log tailing, progress updates, AI response streaming, social media timelines

Use Long Polling

Legacy browser support, corporate proxy environments that block WebSocket/SSE, very low frequency updates

Use HTTP/2 Streams

Multiplexed request/response, gRPC-Web, environments where WebSocket Upgrade is blocked

Rule of thumb: if the client never needs to push data to the server (or does so rarely via REST), SSE is simpler and more reliable. Use WebSocket when you need true bidirectional messaging.
10

Testing & Debugging

Tools and techniques for inspecting, testing, and debugging WebSocket connections in development and production.

Browser DevTools

// Chrome / Edge / Firefox DevTools
// 1. Open F12 > Network tab
// 2. Filter by "WS" (WebSocket)
// 3. Click on a WebSocket connection to inspect:
//    - Headers: Upgrade request/response
//    - Messages: Each frame with timestamp, direction, size
//    - Timing: Connection and first-frame latency

// Programmatic debugging in console:
const ws = new WebSocket('wss://example.com/ws');

// Log every frame
const origSend = ws.send.bind(ws);
ws.send = (data) => {
  console.log('%cSENT', 'color: #28f3ff', data);
  origSend(data);
};
ws.addEventListener('message', (e) => {
  console.log('%cRECV', 'color: #ff3b8d', e.data);
});

CLI Tools

wscat
npx wscat -c wss://echo.websocket.org npx wscat -l 8080

Interactive CLI client and server. Best for quick testing.

websocat
websocat ws://localhost:8080/ws echo "hello" | websocat ws://...

Pipe-friendly, supports binary, TLS, Unix sockets. Install via cargo or brew.

wsdump (Python)
pip install websocket-client wsdump wss://echo.websocket.org

Shows raw frames with timestamps and opcodes.

Wireshark
Filter: websocket

Deep packet inspection of WebSocket frames. Decodes opcodes, payloads, and close codes.

Automated Testing

// Jest / Vitest with mock WebSocket
import { vi, describe, it, expect } from 'vitest';

class MockWebSocket {
  constructor(url) {
    this.url = url;
    this.readyState = WebSocket.CONNECTING;
    this.sent = [];
    setTimeout(() => {
      this.readyState = WebSocket.OPEN;
      this.onopen?.({});
    }, 0);
  }
  send(data) { this.sent.push(data); }
  close(code, reason) {
    this.readyState = WebSocket.CLOSED;
    this.onclose?.({ code, reason, wasClean: true });
  }
  // Simulate server message
  _receive(data) {
    this.onmessage?.({ data });
  }
}

describe('ChatClient', () => {
  it('sends auth on connect', async () => {
    global.WebSocket = MockWebSocket;
    const client = new ChatClient('wss://test/ws', 'my-token');
    await new Promise(r => setTimeout(r, 10));

    const authMsg = JSON.parse(client.ws.sent[0]);
    expect(authMsg.type).toBe('auth');
    expect(authMsg.token).toBe('my-token');
  });

  it('reconnects on abnormal close', async () => {
    global.WebSocket = MockWebSocket;
    const client = new ChatClient('wss://test/ws', 'tok');
    await new Promise(r => setTimeout(r, 10));

    client.ws.onclose({ code: 1006, wasClean: false });
    // Should schedule reconnect
    expect(client.retryCount).toBe(1);
  });
});

Load Testing

# Artillery (Node.js)
npm install -g artillery
cat > ws-load.yml << 'YAML'
config:
  target: "wss://api.example.com"
  phases:
    - duration: 60
      arrivalRate: 100      # 100 new connections/sec
      maxVusers: 5000
  ws:
    rejectUnauthorized: false

scenarios:
  - engine: ws
    flow:
      - send: '{"type":"auth","token":"test"}'
      - think: 1
      - send: '{"type":"chat","msg":"hello"}'
      - think: 5
      - send: '{"type":"chat","msg":"world"}'
      - think: 10
YAML
artillery run ws-load.yml

# k6 (Go-based, great for CI)
# k6 run ws-test.js

Common Issues & Fixes

SymptomLikely CauseFix
Connection closes immediatelyProxy not forwarding UpgradeConfigure proxy with proxy_http_version 1.1 and Upgrade headers
Connection drops after 60sProxy/LB idle timeoutImplement ping/pong heartbeat < timeout interval
Close code 1006Abnormal closure (no close frame)Check network, server crashes, proxy timeouts
Close code 1009Message too bigIncrease server maxPayload or chunk data
SecurityError on connectMixed content (ws:// on HTTPS page)Use wss:// on HTTPS pages
Messages arrive out of orderMultiple connections or async handlersSequence numbers + reorder buffer
Memory leak (server)Not cleaning up closed connectionsRemove from Maps/Sets in onclose
CORS errorsOrigin not allowedServer: validate Origin header in verifyClient