env.dev

WebSockets: The Complete Guide to Real-Time Bidirectional Communication

Master WebSockets: the RFC 6455 protocol, handshake, browser API, server implementations in Node.js, Python, and Go, reconnection strategies, heartbeats, security best practices, pub/sub patterns, and scaling in production.

Last updated:

WebSockets provide full-duplex, bidirectional communication between a client and server over a single, long-lived TCP connection. Defined in RFC 6455 and standardized in 2011, WebSockets eliminate the overhead of repeated HTTP handshakes — a regular HTTP request carries ~600 bytes of headers per message, while a WebSocket frame adds only 2–6 bytes. This makes WebSockets up to 100× more efficient for high-frequency messaging. Over 95% of browsers support WebSockets natively, and they power real-time features in apps like Slack, Discord, Figma, and multiplayer games.

What Is the WebSocket Protocol?

WebSocket is a communication protocol that upgrades an HTTP/1.1 connection to a persistent, bidirectional channel. Unlike HTTP's request-response model, either side can send messages at any time without waiting for the other. The protocol uses ws:// (port 80) for unencrypted connections and wss:// (port 443) for TLS-encrypted connections.

The connection lifecycle has three phases: opening handshake (HTTP Upgrade), data transfer (framed messages), and closing handshake (close frames exchanged by both sides).

How Does the WebSocket Handshake Work?

Every WebSocket connection starts as a standard HTTP request with an Upgrade: websocket header. The client sends a random Base64-encoded key, and the server responds with a derived accept hash to prove it understands the protocol.

Client handshake request
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
Server handshake response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The server computes Sec-WebSocket-Accept by concatenating the client's key with the magic GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, then taking the SHA-1 hash and Base64-encoding the result. After the 101 response, both sides switch from HTTP to the WebSocket framing protocol on the same TCP connection.

WebSocket vs HTTP vs SSE: When to Use What?

FeatureWebSocketSSEHTTP Polling
DirectionBidirectionalServer → Client onlyClient → Server only
Protocolws:// / wss://HTTP/1.1 or HTTP/2HTTP/1.1 or HTTP/2
Header overhead2–6 bytes per frame~600 bytes per event~600 bytes per request
Binary dataNative supportText only (Base64 = 33% overhead)Native support
ReconnectionManual implementationBuilt-in auto-reconnectBuilt-in (new request)
Browser limitNo connection limit6 per domain (HTTP/1.1)No limit
Ideal forChat, games, collaborationFeeds, notifications, dashboardsInfrequent updates

Rule of thumb: Use WebSockets when the client and server both send data frequently (more than once per second) or when latency under 100ms matters. Use SSE when only the server pushes updates. Use HTTP polling when updates are infrequent (less than once per minute).

Browser WebSocket API

The browser's WebSocket API is straightforward — create a connection, listen for events, and send messages. Every modern browser has supported it since 2012.

Client-side WebSocket usage
const socket = new WebSocket('wss://api.example.com/ws');

// Connection opened
socket.addEventListener('open', () => {
  console.log('Connected');
  socket.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }));
});

// Receive messages
socket.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
});

// Handle errors
socket.addEventListener('error', (event) => {
  console.error('WebSocket error:', event);
});

// Connection closed
socket.addEventListener('close', (event) => {
  console.log(`Closed: code=${event.code} reason=${event.reason}`);
});

// Send data (text or binary)
socket.send('Hello server');
socket.send(new Uint8Array([1, 2, 3]));

WebSocket Ready States

The readyState property indicates the current connection status. Always check it before sending to avoid errors.

ValueConstantMeaning
0CONNECTINGHandshake in progress
1OPENConnection established, ready to communicate
2CLOSINGClose frame sent, waiting for acknowledgment
3CLOSEDConnection is closed or could not be opened

Server-Side WebSocket Examples

Most languages have mature WebSocket libraries. Here are minimal server examples in the most common stacks.

Node.js with ws library
import { WebSocketServer } from 'ws';

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

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

  ws.on('message', (data) => {
    const message = JSON.parse(data.toString());
    // Broadcast to all connected clients
    for (const client of wss.clients) {
      if (client.readyState === 1) {
        client.send(JSON.stringify(message));
      }
    }
  });

  ws.on('close', () => console.log('Client disconnected'));
});
Python with websockets library
import asyncio
import websockets

async def handler(websocket):
    async for message in websocket:
        # Echo back to sender
        await websocket.send(f"Echo: {message}")

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

asyncio.run(main())
Go with gorilla/websocket
package main

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

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool { return true },
}

func handler(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()

    for {
        msgType, msg, err := conn.ReadMessage()
        if err != nil {
            break
        }
        conn.WriteMessage(msgType, msg)
    }
}

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

How Do You Handle Reconnection and Heartbeats?

Unlike SSE, WebSockets have no built-in reconnection. You must implement retry logic yourself. Additionally, idle connections can be silently dropped by proxies, load balancers, or firewalls — ping/pong frames solve this.

Reconnection with exponential backoff
function createWebSocket(url) {
  let ws;
  let retries = 0;
  const maxRetries = 10;
  const baseDelay = 1000; // 1 second

  function connect() {
    ws = new WebSocket(url);

    ws.addEventListener('open', () => {
      retries = 0; // Reset on successful connection
    });

    ws.addEventListener('close', (event) => {
      if (event.code !== 1000 && retries < maxRetries) {
        const delay = Math.min(baseDelay * 2 ** retries, 30000);
        retries++;
        setTimeout(connect, delay);
      }
    });

    return ws;
  }

  return connect();
}
Server-side heartbeat (Node.js)
// Ping every 30 seconds, terminate if no pong within 10 seconds
const interval = setInterval(() => {
  for (const ws of wss.clients) {
    if (!ws.isAlive) {
      ws.terminate();
      continue;
    }
    ws.isAlive = false;
    ws.ping();
  }
}, 30000);

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
});

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

WebSocket Security Best Practices

The WebSocket protocol does not handle authentication or authorization. A WebSocket opened from an authenticated page does not automatically receive credentials. You must secure the connection explicitly.

Always use wss://

Unencrypted ws:// exposes data to interception and is blocked by many proxies. TLS is non-negotiable in production.

Validate the Origin header

Check the Origin header during the handshake to prevent Cross-Site WebSocket Hijacking (CSWSH) attacks.

Authenticate before data exchange

Send a token (JWT or session ID) as the first message or via a query parameter. Reject unauthenticated connections immediately.

Set message size limits

Unbounded messages can exhaust server memory. Enforce a maximum payload size (e.g., 1 MB) and reject oversized frames.

Rate-limit connections and messages

Prevent abuse by limiting connections per IP and messages per second. Close connections that exceed thresholds.

Validate all incoming data

Treat every WebSocket message as untrusted input. Parse, validate schemas, and sanitize to prevent injection attacks.

WebSocket Close Codes

When a connection closes, the close frame includes a numeric code and optional reason string. Understanding these codes is essential for debugging.

CodeNameMeaning
1000Normal ClosureClean shutdown — both sides agreed to close
1001Going AwayServer shutting down or browser navigating away
1002Protocol ErrorEndpoint received a malformed frame
1003Unsupported DataReceived data type that cannot be handled
1006Abnormal ClosureConnection lost without a close frame (network drop)
1008Policy ViolationMessage violates server policy (e.g., too large)
1011Internal ErrorServer encountered an unexpected condition
1012Service RestartServer is restarting — client should reconnect
1013Try Again LaterServer is overloaded — client should back off

Common WebSocket Patterns

Pub/Sub (Channels)

Most real-time apps use a publish/subscribe model where clients subscribe to channels and receive only relevant messages.

Simple pub/sub server
const channels = new Map(); // channel -> Set<WebSocket>

wss.on('connection', (ws) => {
  ws.on('message', (raw) => {
    const msg = JSON.parse(raw.toString());

    if (msg.type === 'subscribe') {
      if (!channels.has(msg.channel)) channels.set(msg.channel, new Set());
      channels.get(msg.channel).add(ws);
    }

    if (msg.type === 'publish') {
      const subscribers = channels.get(msg.channel);
      if (!subscribers) return;
      const payload = JSON.stringify({ channel: msg.channel, data: msg.data });
      for (const client of subscribers) {
        if (client.readyState === 1) client.send(payload);
      }
    }
  });

  ws.on('close', () => {
    for (const subs of channels.values()) subs.delete(ws);
  });
});

Binary Messaging

WebSockets support binary data natively via ArrayBuffer and Blob. This is critical for gaming, audio/video streaming, and file transfer where Base64 encoding would add 33% overhead.

Sending and receiving binary data
// Client: send binary
const buffer = new Float32Array([1.5, 2.7, 3.14]);
socket.binaryType = 'arraybuffer';
socket.send(buffer.buffer);

// Client: receive binary
socket.addEventListener('message', (event) => {
  if (event.data instanceof ArrayBuffer) {
    const floats = new Float32Array(event.data);
    console.log('Received floats:', floats);
  }
});

Scaling WebSockets in Production

A single Node.js server can handle ~50,000–100,000 concurrent WebSocket connections depending on message frequency and payload size. Beyond that, you need horizontal scaling strategies.

Sticky sessions

Load balancers must route a client to the same backend server for the lifetime of the connection. Use IP hash or cookie-based affinity.

Redis Pub/Sub for cross-server messaging

When clients on different servers need to communicate, use Redis, NATS, or a message broker as a backplane to fan out messages.

Connection draining

During deployments, stop accepting new connections on old instances, send close code 1012 (Service Restart), and let clients reconnect to new instances.

Monitor bufferedAmount

If socket.bufferedAmount grows, the client cannot keep up. Apply backpressure by pausing sends or dropping low-priority messages.

Frequently Asked Questions

Can WebSockets work through corporate proxies and firewalls?

Yes, when using wss:// (TLS). Most corporate proxies pass through encrypted traffic on port 443. Unencrypted ws:// on port 80 is frequently blocked or intercepted by proxies. Always use wss:// in production.

Do WebSockets maintain state across page reloads?

No. A WebSocket connection is tied to the page lifecycle. When the user refreshes or navigates away, the connection closes. You must reconnect and re-subscribe on page load. Store any critical state server-side or in localStorage.

How many WebSocket connections can a single server handle?

A single server can typically handle 50,000–100,000 concurrent connections. Each connection uses ~10–50 KB of memory. The practical limit depends on message frequency, payload size, and server resources rather than the protocol itself.

What is the difference between WebSocket and Socket.IO?

WebSocket is a browser-native protocol defined in RFC 6455. Socket.IO is a JavaScript library that uses WebSockets as its primary transport but adds automatic reconnection, room-based broadcasting, binary streaming, and fallback to HTTP long-polling when WebSockets are unavailable. Socket.IO is not a WebSocket implementation — a Socket.IO client cannot connect to a plain WebSocket server.

Should I use WebSockets or Server-Sent Events (SSE)?

Use SSE when only the server pushes data (notifications, live feeds, dashboards). Use WebSockets when both client and server send data frequently (chat, gaming, collaborative editing) or when you need binary data support. SSE is simpler to implement, auto-reconnects, and works with HTTP/2 multiplexing.

How do I authenticate a WebSocket connection?

The WebSocket protocol has no built-in authentication. The most secure approach is to authenticate via HTTP first (obtain a short-lived token), then pass that token either as a query parameter in the WebSocket URL or as the first message after connection. Validate the token server-side before accepting any further messages.

References