· 4 min read
Scaling WebSockets horizontally with Redis pub/sub
One WebSocket server is easy. Two break your chat. How to fan out messages across instances with Redis, plus presence, heartbeats, back-pressure and graceful shutdown.
- #websocket
- #redis
- #nodejs
- #real-time
- #scaling

Real-time features - chat, live dashboards, notifications, multiplayer cursors - usually start with a single WebSocket server. It keeps every connection in memory, and broadcasting a message is a simple loop.
Then traffic grows, you add a second instance behind a load balancer, and things quietly break.
The problem
Each server only knows its own sockets; Redis pub/sub lets every server hear every message
A WebSocket is a long-lived connection to one specific server. If Alice is connected to server 1 and Bob to server 2, server 1 has no idea Bob exists. When Alice sends a message to their room, server 1 loops over its sockets, and Bob never sees it.
You need a shared bus that every instance listens to. Redis pub/sub is the simplest good answer: a server PUBLISHes to a channel, and every server SUBSCRIBEd to that channel receives the message and forwards it to its local sockets.
The implementation
Here's a minimal room-based server with ws and ioredis. Clients connect to /rooms/:roomId.
import { createServer } from 'node:http';
import Redis from 'ioredis';
import { WebSocketServer, WebSocket } from 'ws';
const pub = new Redis(process.env.REDIS_URL!);
const sub = new Redis(process.env.REDIS_URL!); // a subscribed connection can't run other commands
const rooms = new Map<string, Set<WebSocket>>();
// Every instance hears every room message and delivers it locally
await sub.psubscribe('room:*');
sub.on('pmessage', (_pattern, channel, message) => {
const sockets = rooms.get(channel.slice('room:'.length));
if (!sockets) return;
for (const socket of sockets) {
if (socket.readyState === WebSocket.OPEN && socket.bufferedAmount < 1_000_000) {
socket.send(message);
}
}
});
const server = createServer();
const wss = new WebSocketServer({ server });
wss.on('connection', (socket, request) => {
const roomId = new URL(request.url!, 'http://localhost').pathname.split('/')[2];
if (!roomId) return socket.close(1008, 'room required');
const members = rooms.get(roomId) ?? new Set();
members.add(socket);
rooms.set(roomId, members);
socket.on('message', (data) => {
// Don't send directly - publish, so users on other instances get it too
pub.publish(`room:${roomId}`, data.toString());
});
socket.on('close', () => {
members.delete(socket);
if (members.size === 0) rooms.delete(roomId);
});
});
server.listen(Number(process.env.PORT ?? 8080));Two details matter here:
- Two Redis connections. Once a connection enters subscribe mode, it can only subscribe and unsubscribe - so publishing needs its own connection.
- Always publish, never send directly. Even when the sender and receiver are on the same instance, going through Redis keeps one code path and one ordering.
psubscribe('room:*') means every instance receives every room message, even for rooms it has no members in. That's fine for small and medium systems. At larger scale, subscribe per room instead: subscribe when a room gets its first local member, unsubscribe when it loses its last one.
The load balancer
WebSockets start as an HTTP request with an Upgrade header. Your proxy must pass it through. In Nginx:
location /rooms/ {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
}With raw WebSockets you don't need sticky sessions - each connection stays on the server it opened on anyway. You do need them with Socket.IO if its HTTP long-polling fallback is enabled, because those requests must keep hitting the same server.
Heartbeats: find the dead connections
A phone that loses signal doesn't send a close frame. Its socket stays "open" on your server forever unless you check. Ping every client periodically and drop the ones that don't answer:
const alive = new WeakMap<WebSocket, boolean>();
wss.on('connection', (socket) => {
alive.set(socket, true);
socket.on('pong', () => alive.set(socket, true));
});
setInterval(() => {
for (const socket of wss.clients) {
if (!alive.get(socket)) {
socket.terminate();
continue;
}
alive.set(socket, false);
socket.ping();
}
}, 30_000);Thirty seconds also keeps idle connections alive through proxies and load balancers that close quiet connections.
Presence: who's online?
In-memory sets don't work across instances either, so keep presence in Redis too. A simple and robust pattern is a sorted set per room, scored by the last heartbeat time:
// on connect and on every pong
await pub.zadd(`presence:${roomId}`, Date.now(), userId);
// who's online = seen in the last 60 seconds
const online = await pub.zrangebyscore(`presence:${roomId}`, Date.now() - 60_000, '+inf');Because entries expire by timestamp rather than by a disconnect event, a crashed server can't leave "ghost" users online forever.
Back-pressure
socket.send() never blocks. If a client is on a slow network, messages pile up in memory on your server. The bufferedAmount check above skips sending to clients with more than ~1 MB queued. For important streams, disconnect them instead, and let the client reconnect and fetch what it missed.
Graceful shutdown
Deploys restart servers. Tell clients to reconnect instead of letting connections die abruptly:
process.on('SIGTERM', () => {
for (const socket of wss.clients) socket.close(1012, 'server restarting');
wss.close();
server.close(() => process.exit(0));
});Close code 1012 means "service restart". On the client, reconnect with exponential backoff and jitter, so ten thousand clients don't all reconnect in the same second.
Know the limits of pub/sub
Redis pub/sub is fire-and-forget: if an instance is disconnected from Redis for a moment, it misses those messages forever, and nothing is stored. That's acceptable for typing indicators and cursors, but not for chat history.
For messages that must not be lost:
- Persist first (in your database, or a Redis Stream with
XADD), then publish. - On reconnect, the client asks for everything after the last message ID it saw.
If you use Socket.IO, @socket.io/redis-adapter implements the same fan-out for you, and the sharded adapter spreads the load across Redis Cluster nodes.
Summary
- A WebSocket belongs to one server; a shared bus connects them.
- Redis pub/sub: publish on one connection, subscribe on another, deliver locally.
- Pass
Upgradeheaders through the proxy; sticky sessions only for long-polling. - Heartbeats, Redis-backed presence and back-pressure keep it healthy.
- Pub/sub doesn't store anything - persist first when messages matter.
Iktiup Mahmud