Web Development

Introduction to WebSockets: Real-Time Web Applications

Introduction to WebSockets: Real-Time Web Applications

Traditional HTTP follows a request-response model: the client asks, the server answers, and the connection effectively ends. This works for fetching web pages and submitting forms, but it breaks down for applications that need continuous, real-time data flow — chat applications, live dashboards, collaborative document editing, multiplayer games, financial tickers, and live notification feeds. These applications need the server to push data to the client instantly, without waiting for a request. That is what WebSockets provide.

This guide covers the WebSocket protocol from handshake to production deployment, with working code examples for both client and server, a detailed comparison with alternative real-time technologies, and practical patterns for building reliable real-time applications.

How the WebSocket Protocol Works

A WebSocket connection begins life as a standard HTTP request. The client sends an HTTP GET with special headers requesting an “upgrade” to the WebSocket protocol:

# Client request
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com

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

The 101 Switching Protocols response confirms the upgrade. From this point, the TCP connection stays open and both sides communicate through WebSocket frames — lightweight binary messages with just 2-14 bytes of overhead per frame, compared to hundreds of bytes per HTTP request for headers, cookies, and other metadata.

Key Protocol Characteristics

  • Full-duplex — Both client and server can send messages at any time, simultaneously. Neither needs to wait for the other to finish.
  • Persistent connection — The TCP connection remains open until either side explicitly closes it. No reconnection overhead between messages.
  • Low overhead — After the initial HTTP handshake, messages carry minimal framing. A WebSocket frame header is 2 bytes for small messages (under 126 bytes), compared to hundreds of bytes of HTTP headers per request.
  • Text and binary — WebSocket supports both text frames (UTF-8 strings) and binary frames (ArrayBuffer, Blob). This makes it suitable for both chat messages and binary data like audio streams or game state.
  • Ping/pong — Built-in heartbeat mechanism. Either side can send a ping frame; the other must respond with a pong. This detects broken connections and keeps the connection alive through proxies and load balancers that might otherwise close idle connections.

Client-Side Implementation

The WebSocket API in the browser is straightforward. Four event handlers cover the entire lifecycle:

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

ws.addEventListener('open', () => {
  console.log('Connected to server');
  ws.send(JSON.stringify({
    type: 'join',
    room: 'general',
    user: 'jane_dev',
  }));
});

ws.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);

  switch (data.type) {
    case 'chat':
      displayMessage(data.user, data.text);
      break;
    case 'user_joined':
      displayNotification(`${data.user} joined the room`);
      break;
    case 'user_left':
      displayNotification(`${data.user} left the room`);
      break;
  }
});

ws.addEventListener('close', (event) => {
  console.log(`Disconnected: code=${event.code} reason=${event.reason}`);
  // Implement reconnection logic here
});

ws.addEventListener('error', (event) => {
  console.error('WebSocket error:', event);
});

Connection States

The WebSocket object has a readyState property that tracks the connection lifecycle:

  • WebSocket.CONNECTING (0) — Connection is being established
  • WebSocket.OPEN (1) — Connection is open and ready to communicate
  • WebSocket.CLOSING (2) — Connection is in the process of closing
  • WebSocket.CLOSED (3) — Connection is closed or could not be opened

Always check readyState before sending messages. Calling ws.send() on a closed or closing connection throws an error.

Server-Side Implementation with Node.js

The ws library is the most widely used WebSocket implementation for Node.js. It is fast, lightweight, and provides both server and client functionality:

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

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

// Track connected clients by room
const rooms = new Map();

wss.on('connection', (ws, request) => {
  const clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
  console.log(`Client connected from ${clientIp}`);

  let currentRoom = null;
  let username = null;

  ws.on('message', (raw) => {
    let data;
    try {
      data = JSON.parse(raw.toString());
    } catch {
      ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
      return;
    }

    switch (data.type) {
      case 'join':
        currentRoom = data.room;
        username = data.user;

        if (!rooms.has(currentRoom)) {
          rooms.set(currentRoom, new Set());
        }
        rooms.get(currentRoom).add(ws);

        // Notify room members
        broadcast(currentRoom, {
          type: 'user_joined',
          user: username,
          timestamp: Date.now(),
        }, ws);
        break;

      case 'chat':
        if (!currentRoom) return;
        broadcast(currentRoom, {
          type: 'chat',
          user: username,
          text: data.text,
          timestamp: Date.now(),
        });
        break;
    }
  });

  ws.on('close', () => {
    if (currentRoom && rooms.has(currentRoom)) {
      rooms.get(currentRoom).delete(ws);
      broadcast(currentRoom, {
        type: 'user_left',
        user: username,
        timestamp: Date.now(),
      });
      if (rooms.get(currentRoom).size === 0) {
        rooms.delete(currentRoom);
      }
    }
  });

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

// Broadcast to all clients in a room
function broadcast(room, data, exclude = null) {
  const message = JSON.stringify(data);
  const clients = rooms.get(room);
  if (!clients) return;

  for (const client of clients) {
    if (client !== exclude && client.readyState === 1) {
      client.send(message);
    }
  }
}

// Heartbeat interval — detect and clean up dead connections
const heartbeat = setInterval(() => {
  for (const ws of wss.clients) {
    if (!ws.isAlive) {
      ws.terminate();
      continue;
    }
    ws.isAlive = false;
    ws.ping();
  }
}, 30000);

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

server.listen(8080, () => {
  console.log('WebSocket server running on port 8080');
});

Reconnection Strategy

WebSocket connections drop. Networks switch from WiFi to cellular. Servers restart during deployments. Load balancers close idle connections. A production WebSocket client must handle reconnection gracefully:

class ReconnectingWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.maxRetries = options.maxRetries || 10;
    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.addEventListener('open', () => {
      this.retryCount = 0; // Reset on successful connection
      this.handlers.open.forEach(fn => fn());
    });

    this.ws.addEventListener('message', (event) => {
      this.handlers.message.forEach(fn => fn(event));
    });

    this.ws.addEventListener('close', (event) => {
      this.handlers.close.forEach(fn => fn(event));

      if (event.code !== 1000 && this.retryCount < this.maxRetries) {
        const delay = Math.min(
          this.baseDelay * Math.pow(2, this.retryCount),
          this.maxDelay
        );
        // Add jitter to prevent thundering herd
        const jitter = delay * 0.2 * Math.random();
        setTimeout(() => this.connect(), delay + jitter);
        this.retryCount++;
      }
    });
  }

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

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

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

The exponential backoff with jitter prevents the “thundering herd” problem — when a server restarts, thousands of clients should not all reconnect at the exact same moment. Staggering reconnection attempts distributes the load.

WebSockets vs. Alternative Technologies

WebSockets are not the only real-time communication mechanism. Each alternative has specific strengths:

Short Polling

The client sends a request every N seconds (e.g., every 5 seconds) to check for updates. Simple to implement but wasteful: most requests return no new data, yet each incurs full HTTP overhead. Suitable only for low-frequency updates where simplicity outweighs efficiency.

Long Polling

The client sends a request that the server holds open until new data is available. When data arrives, the server responds and the client immediately sends a new request. This reduces wasted requests but still incurs HTTP overhead per message and requires careful server-side timeout management.

Server-Sent Events (SSE)

SSE provides a one-directional stream from server to client over a standard HTTP connection. The server sends events; the client listens. SSE has several advantages over WebSockets for certain use cases:

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

  const sendEvent = (data) => {
    res.write(`event: update`);
    res.write(`data: ${JSON.stringify(data)}`);
  };

  // Send events when data changes
  const interval = setInterval(() => {
    sendEvent({ price: getStockPrice('AAPL'), timestamp: Date.now() });
  }, 1000);

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

// Client
const source = new EventSource('/events');
source.addEventListener('update', (event) => {
  const data = JSON.parse(event.data);
  updatePriceDisplay(data.price);
});

SSE advantages over WebSockets:

  • Automatic reconnection — The EventSource API handles reconnection natively. WebSockets require custom reconnection logic.
  • Event IDs and replay — SSE supports event IDs. On reconnection, the client sends the last received ID, and the server can replay missed events.
  • HTTP/2 multiplexing — SSE connections share an HTTP/2 connection, while WebSockets require separate TCP connections.
  • Simpler infrastructure — SSE works through standard HTTP proxies, load balancers, and CDNs without special configuration. WebSockets require proxy support for the protocol upgrade.

When to Choose Each Technology

Use Case Best Technology Reason
Chat, messaging WebSocket Bidirectional, low latency
Live notifications SSE Server-to-client only, auto-reconnect
Stock tickers, scores SSE One-directional data stream
Collaborative editing WebSocket Bidirectional with conflict resolution
Multiplayer games WebSocket Bidirectional, minimal latency critical
Dashboard updates SSE Server pushes periodic updates
Infrequent updates Polling Simplest, no persistent connection

Socket.IO: The Practical Choice

Socket.IO is the most popular real-time library for Node.js applications. It builds on top of WebSockets but adds features that raw WebSockets lack:

  • Automatic transport fallback — If WebSockets are unavailable (corporate firewalls, misconfigured proxies), Socket.IO falls back to long polling transparently.
  • Room-based messaging — Built-in support for joining rooms and broadcasting to room members.
  • Acknowledgements — Callback-based confirmation that a message was received and processed.
  • Binary support — Automatic serialization/deserialization of binary data alongside JSON.
  • Reconnection — Built-in reconnection with exponential backoff.
  • Multiplexing — Multiple “namespaces” over a single connection, similar to channels.
// Server
import { Server } from 'socket.io';

const io = new Server(httpServer, {
  cors: { origin: 'https://example.com' },
});

io.on('connection', (socket) => {
  console.log(`User connected: ${socket.id}`);

  socket.on('join_room', (room) => {
    socket.join(room);
    socket.to(room).emit('user_joined', {
      user: socket.id,
      timestamp: Date.now(),
    });
  });

  socket.on('chat_message', (data, callback) => {
    // Broadcast to room
    socket.to(data.room).emit('chat_message', {
      user: socket.id,
      text: data.text,
      timestamp: Date.now(),
    });
    // Acknowledge receipt
    callback({ status: 'delivered' });
  });

  socket.on('disconnect', (reason) => {
    console.log(`User disconnected: ${socket.id} (${reason})`);
  });
});

// Client
import { io } from 'socket.io-client';

const socket = io('https://example.com');

socket.on('connect', () => {
  socket.emit('join_room', 'general');
});

socket.on('chat_message', (data) => {
  displayMessage(data.user, data.text);
});

// Send with acknowledgement
socket.emit('chat_message', { room: 'general', text: 'Hello!' }, (ack) => {
  console.log('Message status:', ack.status);
});

Note that Socket.IO is not a WebSocket implementation — it is a higher-level protocol that uses WebSockets as a transport. A plain WebSocket client cannot connect to a Socket.IO server and vice versa. If interoperability with non-JavaScript clients is important, use the raw ws library instead.

Production Considerations

Scaling WebSocket Servers

A single WebSocket server can handle tens of thousands of concurrent connections. But when you need horizontal scaling (multiple server instances behind a load balancer), a problem emerges: a client connected to Server A cannot receive messages broadcast from Server B.

The solution is a pub/sub broker — typically Redis — that sits between server instances. When Server A needs to broadcast a message, it publishes to Redis. All server instances subscribe to the channel and forward the message to their connected clients.

Socket.IO provides the @socket.io/redis-adapter for this purpose. For raw WebSockets, implement the pub/sub pattern directly with Redis or another message broker.

Load Balancer Configuration

WebSocket connections require sticky sessions (session affinity) at the load balancer level. If a client’s reconnection attempt hits a different server instance, the new server has no context for that session. Configure your load balancer to route requests from the same client to the same server using IP hashing or cookie-based affinity.

The load balancer must also support the HTTP Upgrade mechanism. Nginx requires the proxy_set_header Upgrade and proxy_set_header Connection "upgrade" directives. AWS ALB supports WebSockets natively.

Security

  • Always use WSS — The wss:// protocol encrypts WebSocket traffic with TLS, just as HTTPS encrypts HTTP. Never use unencrypted ws:// in production.
  • Authentication — Authenticate during the HTTP upgrade handshake, not after the WebSocket connection is established. Check cookies, tokens, or headers before accepting the upgrade.
  • Rate limiting — Limit the number of messages a client can send per second to prevent abuse. A malicious client flooding messages can overwhelm the server and degrade service for other users.
  • Input validation — Treat every incoming WebSocket message as untrusted input. Parse, validate, and sanitize before processing.
  • Origin checking — Verify the Origin header during the handshake to prevent cross-site WebSocket hijacking.

Real-World Use Cases

WebSockets power some of the most interactive features on the web. Chat applications (Slack, Discord, WhatsApp Web) use WebSockets for instant message delivery. Collaborative editors (Google Docs, Figma, Notion) use WebSockets to synchronize changes between users in real time. Financial platforms use WebSockets for live price feeds where even a one-second delay means stale data. Online multiplayer games use WebSockets for game state synchronization where latency directly affects gameplay quality.

When building these applications, performance optimization matters at every level — from the WebSocket frame size to how efficiently you update the DOM when new data arrives. Consider using a modern UI framework that handles efficient re-rendering, and pair it with a capable editor that provides WebSocket debugging support.

Getting Started

The fastest path to a working WebSocket application depends on your needs. For prototyping or small applications, the browser’s native WebSocket API plus the ws npm package on the server is all you need. For production applications needing rooms, acknowledgements, and automatic reconnection, Socket.IO handles the infrastructure so you can focus on application logic. For applications where every millisecond of latency matters (games, trading), stay close to the raw WebSocket protocol and minimize library overhead.

WebSockets transformed what is possible in the browser. Combined with responsive design for cross-device support and REST APIs for standard CRUD operations, they enable rich, interactive experiences that rival native applications.

Frequently Asked Questions

How many concurrent WebSocket connections can a single server handle?

A well-tuned Node.js server can handle 50,000 to 100,000 concurrent WebSocket connections on a single machine with 4-8 GB of RAM. Each idle connection consumes roughly 20-50 KB of memory. The practical limit is usually memory, not CPU, since idle connections consume very little processing power. Active connections sending frequent messages reduce this number because message processing consumes CPU.

Do WebSocket connections work through corporate firewalls and proxies?

WebSocket over TLS (wss://) works through most corporate firewalls because the traffic looks like regular HTTPS during the initial handshake. Unencrypted WebSocket (ws://) is frequently blocked by firewalls and transparent proxies that do not understand the Upgrade header. Always use wss:// in production. If WebSocket connections are unreliable in your target environment, Socket.IO’s automatic fallback to long polling provides a safety net.

Should I use WebSockets or Server-Sent Events for live notifications?

For notifications where the server pushes updates and the client only listens, SSE is the better choice. It has automatic reconnection, event replay on reconnect, works through standard HTTP infrastructure, and is simpler to implement. WebSockets are warranted when the client also needs to send data back — read receipts, typing indicators, or interactive acknowledgements. If notifications are one-directional, SSE avoids the complexity of managing a full-duplex connection.

What happens to queued messages when a WebSocket connection drops and reconnects?

By default, messages sent while a client is disconnected are lost. The WebSocket protocol has no built-in message persistence or replay mechanism. To handle this, assign an incrementing ID to each message and persist messages server-side (in Redis, a database, or an in-memory buffer). When a client reconnects, it sends the ID of the last message it received. The server replays all messages since that ID. This pattern is called “event sourcing” or “message replay” and is standard in production real-time systems.