Real-Time Features with WebSockets

· 11 min read · Updated March 21, 2026 · intermediate
websockets real-time node javascript fullstack ws

What Problem Do WebSockets Solve?

Before WebSockets, if you wanted to show a user new notifications the moment they arrived, you had a problem. In HTTP, the client always starts the conversation — the client sends a request, the server sends a response, and then the connection closes. The server has no way to reach out to the client unprompted.

You could work around this with polling: the client asks the server “any news?” every few seconds. This works, but it wastes bandwidth asking when there might be nothing to say, and it adds delay when there is something to say.

A WebSocket solves this by keeping the connection open permanently. Once a client connects, both the client and the server can send messages to each other whenever they want, as many times as they want, without needing to re-establish a connection each time. Think of it like a phone call versus sending emails — a call stays open so both sides can talk freely.

WebSockets are the right tool when the server needs to push data to the client without the client asking first. Common examples include chat applications, collaborative editors (like Google Docs), live dashboards, multiplayer games, and notifications.

Setting Up a WebSocket Server with Node.js

The most common Node.js library for WebSockets is called ws. Install it with:

npm install ws

ws can run in two ways. The simplest is as a standalone server on its own port. In production, you more often attach it to an existing HTTP server so the same port handles both regular web routes and WebSocket connections.

Here is the simplest possible WebSocket server:

const { WebSocketServer } = require('ws');

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

wss.on('connection', (socket, request) => {
  // A new client connected. 'socket' is their connection.
  // 'request' is the original HTTP handshake request.

  console.log('New client connected from:', request.socket.remoteAddress);

  socket.on('message', (data) => {
    // 'data' is a Buffer in Node.js — convert to string first
    const text = data.toString();
    console.log('Received:', text);

    // Echo the message back to the client who sent it
    socket.send(`Echo: ${text}`);
  });

  socket.on('close', (code, reason) => {
    // 'code' is a WebSocket close code (1000 = normal)
    // 'reason' is a human-readable string
    console.log('Client disconnected:', code, reason.toString());
  });

  socket.on('error', (error) => {
    // Always handle errors — an error is always followed by a close event
    console.error('Socket error:', error.message);
  });
});

console.log('WebSocket server listening on ws://localhost:8080');

The request object that comes with every new connection is the original HTTP request from the handshake. You can read headers, the URL path, and query parameters from it — useful for routing and authentication.

The socket object represents the client’s connection. It has the same API on the server side as the browser’s WebSocket object: send() to transmit data, close() to disconnect, and event listeners for message, close, and error.

Broadcasting to Multiple Clients

A WebSocket server usually needs to send messages to more than one client. Chat is the classic example — when one person sends a message, everyone in the chat should receive it.

The ws library keeps track of all connected clients in wss.clients, which is a JavaScript Set:

function broadcast(message) {
  // Go through every connected client
  wss.clients.forEach((client) => {
    // Only send if the connection is actually open
    // readyState 1 = OPEN, which means the connection is active
    if (client.readyState === 1) {
      client.send(message);
    }
  });
}

Always check client.readyState before sending. If a client’s connection has dropped but the server has not yet noticed, trying to send() will throw an InvalidStateError. The numeric value 1 is equivalent to the WebSocket.OPEN constant — either works, but 1 avoids needing an extra import.

Sending Binary Data

By default, WebSockets transmit strings. You can also send binary data using ArrayBuffer or Blob on the client side, and Buffer on the server side:

// Client: sending binary
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setUint8(0, 42);
socket.send(buffer);

// Server: receiving binary
socket.on('message', (data) => {
  if (Buffer.isBuffer(data)) {
    console.log('Received binary:', data); // <Buffer 2a 00 00 00 00 00 00 00>
    console.log('First byte:', data[0]);   // 42
  }
});

This is useful for real-time gaming, streaming audio, or anywhere you need to move structured binary data efficiently.

Implementing Rooms

Raw WebSockets have no concept of “rooms” — every connection is just a socket in a flat list. If you want to group clients together (so messages only go to people in the same chat room, for example), you build that grouping yourself.

The standard approach is a Map that tracks which sockets are in which room:

// room name -> Set of sockets in that room
const rooms = new Map();

function joinRoom(socket, roomName) {
  if (!rooms.has(roomName)) {
    rooms.set(roomName, new Set());
  }
  rooms.get(roomName).add(socket);
  socket.room = roomName; // tag the socket so we know which room it belongs to
}

function broadcastToRoom(roomName, message, excludeSocket = null) {
  const room = rooms.get(roomName);
  if (!room) return;

  const data = JSON.stringify(message);
  room.forEach((client) => {
    if (client !== excludeSocket && client.readyState === 1) {
      client.send(data);
    }
  });
}

Then inside your connection handler, listen for a join message type and route chat messages to the right room:

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

  switch (msg.type) {
    case 'join':
      // Client wants to join a room
      joinRoom(socket, msg.room);
      broadcastToRoom(msg.room, {
        type: 'system',
        text: `Someone joined ${msg.room}`
      }, socket);
      break;

    case 'chat':
      // Client sent a chat message to their current room
      if (socket.room) {
        broadcastToRoom(socket.room, {
          type: 'message',
          text: msg.text
        });
      }
      break;
  }
});

This pattern gives you full control over grouping and routing without any library-specific abstractions.

Scaling to Multiple Server Processes

A single Node.js process can handle tens of thousands of WebSocket connections, but at some point you may need multiple servers. The problem is that a client connected to Server A has no way to reach a client on Server B — their sockets are on different machines.

The solution is a pub/sub layer, typically Redis. When Server A receives a message it wants to broadcast across all servers, it publishes that message to a Redis channel. Redis immediately fans it out to Servers B and C (and any others), and each delivers it to its own connected clients.

const { createClient } = require('redis');

async function setupRedis() {
  const publisher = createClient();
  const subscriber = createClient();

  await publisher.connect();
  await subscriber.connect();

  // When this server receives a message, publish it to Redis
  socket.on('message', (data) => {
    publisher.publish('chat', data);
  });

  // Subscribe so this server receives messages from other servers
  subscriber.subscribe('chat', (message) => {
    // 'message' came from a different server — broadcast to our local clients
    broadcast(message);
  });
}

Each hop through Redis adds a small amount of latency (roughly 1 millisecond). For most real-time applications this is perfectly fine. If you need sub-millisecond latency, a single large server with tens of thousands of connections may be the better choice.

One more thing: not all load balancers forward WebSocket upgrade requests correctly. HAProxy, nginx 1.3 or later, and AWS ALB support WebSockets out of the box. If your load balancer silently drops the Upgrade header, clients will connect over plain HTTP instead of WebSocket.

Authenticating WebSocket Connections

WebSocket connections start as HTTP requests, which means you can read HTTP headers during the handshake to authenticate users.

A common approach is to pass a JWT in a query parameter when connecting:

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

On the server, extract and verify that token during the connection event:

wss.on('connection', (socket, request) => {
  // Parse the URL to read query parameters
  const url = new URL(request.url, `http://${request.headers.host}`);
  const token = url.searchParams.get('token');

  if (!token || !verifyToken(token)) {
    // Reject the connection with a custom close code (4000-4999 are for apps)
    socket.close(4001, 'Unauthorized');
    return;
  }

  // Connection is valid — proceed normally
  socket.user = getUserFromToken(token);
});

A few security points worth knowing:

  • Browsers do not enforce CORS headers on WebSocket connections the way they do for fetch(). Any website can attempt a WebSocket connection to your server. Validate the Origin header on the server if this concerns you.
  • wss:// (TLS-encrypted) is mandatory in production. Plain ws:// lets anyone on the network intercept messages. It is also blocked by many corporate proxies.
  • If your JWT expires after 15 minutes but the WebSocket stays open for hours, the user stays authenticated until disconnect. Consider checking auth on every message for sensitive applications.

A React Hook for WebSocket Connections

In React, managing a WebSocket connection by hand in every component leads to duplicate connections, missing cleanup, and bugs. A custom hook encapsulates all the lifecycle logic:

import { useEffect, useRef, useState, useCallback } from 'react';

function useWebSocket(url) {
  const [messages, setMessages] = useState([]);
  const [isConnected, setIsConnected] = useState(false);
  const socketRef = useRef(null);
  const reconnectTimeout = useRef(null);
  const reconnectDelay = useRef(1000);

  const connect = useCallback(() => {
    const socket = new WebSocket(url);
    socketRef.current = socket;

    socket.addEventListener('open', () => {
      setIsConnected(true);
      reconnectDelay.current = 1000; // Reset backoff on successful connect
    });

    socket.addEventListener('message', (event) => {
      try {
        const data = JSON.parse(event.data);
        setMessages((prev) => [...prev, data]);
      } catch {
        // Not JSON — store as raw string
        setMessages((prev) => [...prev, { raw: event.data }]);
      }
    });

    socket.addEventListener('close', () => {
      setIsConnected(false);
      // Exponential backoff: 1s, 2s, 4s, 8s... up to 30s max
      reconnectTimeout.current = setTimeout(() => {
        reconnectDelay.current = Math.min(reconnectDelay.current * 2, 30000);
        connect();
      }, reconnectDelay.current);
    });

    socket.addEventListener('error', (error) => {
      console.error('WebSocket error:', error);
    });
  }, [url]);

  useEffect(() => {
    reconnectDelay.current = 1000; // Reset backoff on URL change (e.g. room switch)
    connect();
    return () => {
      // Clean up: stop reconnect timer and close the socket
      clearTimeout(reconnectTimeout.current);
      if (socketRef.current) {
        socketRef.current.close();
        socketRef.current = null;
      }
    };
  }, [connect]);

  const send = useCallback((data) => {
    if (socketRef.current?.readyState === WebSocket.OPEN) {
      socketRef.current.send(
        typeof data === 'string' ? data : JSON.stringify(data)
      );
    }
  }, []);

  return { messages, isConnected, send };
}

Exponential backoff is important here. WebSockets do not automatically reconnect — if the network drops, the socket is dead. Reconnecting immediately on every network blip causes a storm of connections. Instead, double the wait time between each attempt (capped at 30 seconds) to give transient issues time to resolve. The delay resets in the useEffect cleanup whenever the URL changes, so switching rooms (which changes the URL) starts with a fresh backoff instead of continuing from where it left off.

Using the hook in a component looks like this:

function ChatRoom({ room }) {
  const { messages, isConnected, send } = useWebSocket(
    `wss://chat.example.com/ws?room=${room}`
  );

  return (
    <div>
      <p>{isConnected ? 'Connected' : 'Disconnected — reconnecting...'}</p>
      <ul>
        {messages.map((m, i) => (
          <li key={i}>{m.text || m.raw}</li>
        ))}
      </ul>
      <button onClick={() => send({ type: 'chat', text: 'Hello!' })}>
        Send
      </button>
    </div>
  );
}

The component never touches the raw socket — it just reads state and calls send.

Production Concerns

A few things that do not matter in development but matter a lot when you are serving real users:

Heartbeat. Corporate proxies and cloud load balancers close connections that sit idle for too long. Send some kind of ping every 20–60 seconds to keep the connection alive. On the server you can use the ws library’s built-in ping() method, or just send a JSON message with a type: 'ping' and expect a type: 'pong' response.

Graceful shutdown. If you restart the server without warning, clients simply lose their connection and show an ugly error to users. A proper shutdown notifies each client first:

async function shutdown() {
  console.log('Shutting down...');

  // Stop accepting new connections
  wss.close();

  // Close each existing client cleanly
  const closePromises = Array.from(wss.clients).map((client) => {
    return new Promise((resolve) => {
      if (client.readyState === 1) {
        client.send(JSON.stringify({ type: 'shutdown', text: 'Server restarting' }));
        client.close(1001, 'Server going down');
        client.on('close', resolve);
        setTimeout(resolve, 5000); // Force close after 5s
      } else {
        resolve();
      }
    });
  });

  await Promise.all(closePromises);
  console.log('All clients disconnected');
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Note that wss.close() only stops accepting new connections — it does not touch existing ones. You have to close each client socket individually.

Summary

WebSockets give you a persistent, bidirectional channel between client and server. Once connected, the server can push data the moment something happens instead of waiting for the client to ask.

On the server side, the ws library handles the protocol. You build rooms, authentication, and routing on top of the raw API. Horizontal scaling requires a pub/sub layer (usually Redis) to broadcast across processes. In production, always use wss://, implement heartbeat to survive proxies, and handle reconnection gracefully on the client.

If you find yourself needing rooms, automatic reconnection, or fallback transport from scratch, consider Socket.IO — it adds all of that on top of WebSockets, though it uses its own wire protocol rather than the standard WebSocket format.

See Also