WebSockets don’t inherently scale; they’re a persistent, one-to-one connection.

Let’s watch a simple chat application built with Node.js and Socket.IO handle a sudden influx of users.

Imagine you have a Node.js server running Socket.IO. When a client connects, it establishes a WebSocket connection. When a message is broadcast, Socket.IO iterates through all connected clients on that server instance and sends the message.

// server.js
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
  cors: {
    origin: "*", // In production, restrict this!
    methods: ["GET", "POST"]
  }
});

const PORT = process.env.PORT || 3000;

io.on('connection', (socket) => {
  console.log('A user connected');

  socket.on('chat message', (msg) => {
    io.emit('chat message', msg); // Broadcast to all clients
  });

  socket.on('disconnect', () => {
    console.log('User disconnected');
  });
});

server.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

Now, what happens when you have 10,000 users connected to this single Node.js process? The io.emit('chat message', msg) call has to loop through all 10,000 sockets and send the message. Your single Node.js thread, busy with event loop tasks, will start to lag. Message delivery becomes slow, and eventually, connections might drop or time out. The problem isn’t the WebSocket protocol itself; it’s the single point of failure and processing bottleneck of one server instance.

The core issue is that each Node.js process maintains its own independent set of connected clients. When you broadcast a message using io.emit(), it only sends to clients connected to that specific process. To scale, you need a way for messages sent to one server instance to reach clients connected to other server instances. This is where a message broker comes in.

A common pattern is to use Redis as a pub/sub mechanism. When a server receives a message, instead of emitting it directly to its connected clients, it publishes the message to a Redis channel. All other server instances are subscribed to this channel. When they receive a message from Redis, they then emit it to their local connected clients.

Here’s how you’d modify the server to use Redis:

First, install the necessary packages: npm install redis redis-socket.io

Then, update your server.js:

// server.js (with Redis adapter)
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const redisAdapter = require('redis-socket.io');
const redis = require('redis');

const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
  cors: {
    origin: "*",
    methods: ["GET", "POST"]
  }
});

const PORT = process.env.PORT || 3000;

// Configure Redis clients
const pubClient = redis.createClient({ url: "redis://localhost:6379" }); // Replace with your Redis URL
const subClient = redis.createClient({ url: "redis://localhost:6379" }); // Replace with your Redis URL

// Use Redis adapter for Socket.IO
io.adapter(redisAdapter({ pubClient, subClient }));

io.on('connection', (socket) => {
  console.log('A user connected');

  socket.on('chat message', (msg) => {
    // io.emit() now broadcasts across all server instances via Redis
    io.emit('chat message', msg);
  });

  socket.on('disconnect', () => {
    console.log('User disconnected');
  });
});

server.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

Now, you can run multiple instances of this Node.js server. For example, on your local machine, you could start three servers: PORT=3000 node server.js PORT=3001 node server.js PORT=3002 node server.js

And ensure your Redis server is running. When a client connects to port 3000 and sends a message, that server publishes it to Redis. The other two servers, subscribed to the same Redis channel, receive the message and relay it to their respective connected clients. This distributes the load across multiple Node.js processes.

Load balancing across these Node.js instances is crucial. A common setup is to use a reverse proxy like Nginx or a cloud provider’s load balancer. The load balancer distributes incoming client connections across your Node.js server instances. It’s vital that the load balancer supports sticky sessions (or session affinity) if you were using traditional HTTP sessions, but for WebSockets managed by a Redis adapter, it’s less critical as the Redis adapter handles inter-server communication. However, ensuring clients are distributed reasonably is still good practice.

The redis-socket.io package acts as the communication bridge. When io.emit() is called on any server instance, the adapter serializes the message and publishes it to a Redis channel (e.g., socket.io#). All other server instances, subscribed to this channel, receive the message. They then deserialize it and use their local socket.send() or socket.emit() to deliver it to their directly connected clients. This decouples message broadcasting from direct client management within a single process.

The most surprising thing about scaling WebSockets in Node.js is that the core problem isn’t the WebSocket protocol itself, but how your application distributes the state of connected clients and message broadcasting across multiple server instances. A simple io.emit() is a lie when you have more than one server; it only emits to the local process’s clients.

When you scale horizontally by running multiple Node.js processes, each process thinks it’s the sole owner of the connected clients. The socket.io-redis-adapter (or similar) is the key. It transforms io.emit() from a local broadcast into a distributed broadcast. Each server instance subscribes to a central message bus (Redis pub/sub in this example). When one server needs to broadcast, it publishes the message to the bus. All other servers listening on that bus receive the message and then emit it to their local clients. This way, a message sent to any server instance ends up reaching clients connected to all server instances.

The next hurdle you’ll likely encounter is managing server-side state and ensuring consistency across these distributed instances, especially if your application relies on per-user data or session management.

Want structured learning?

Take the full Nodejs course →