Skip to content
Cloud & Infra Intermediate Tutorial

Scale Node.js WebSockets Across Instances with Redis Pub/Sub

Fan out WebSocket messages across multiple Node.js processes behind nginx using Redis as the backplane.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Sep 8, 2026 · 5 min read
Scale Node.js WebSockets Across Instances with Redis Pub/Sub

What you'll build

Two Node.js WebSocket servers behind an nginx load balancer, wired together by Redis pub/sub, so a message sent by a client on one instance reaches every client on every instance. You'll finish with a working local cluster and a one-command way to prove the fan-out actually crosses process boundaries.

Prerequisites

Verified on macOS 26 against Node.js 26.8.1, ws 8.21.3, node-redis 6.2.1, wscat 6.1.0, Redis 8.6.3 from the official Alpine image, and nginx 1.31.3 via nginx:alpine, under Docker 29.7.2.

Node 20 is the hard floor; node-redis 6 declares engines.node >= 20.0.0 and npm will refuse older runtimes. You need Docker running and four free terminals, with ports 9080, 9081, 9082 and 6379 unused. Everything below runs identically on Linux; the one Linux-specific flag is called out in step 6.

1. Understand the topology

ws tracks connected sockets in wss.clients, a per-process Set. Loop over it to broadcast and you reach only the clients that happened to land on that process. Add a second instance and half your users go silent.

Redis fixes this as a backplane: every instance publishes inbound messages to one channel and subscribes to that same channel. Redis fans each publish out to every subscriber, and each instance delivers to its own local sockets.

graph LR
    A[Client A] --> LB[nginx :9080]
    B[Client B] --> LB
    LB --> N1[node :9081]
    LB --> N2[node :9082]
    N1 <--> R[(Redis pub/sub)]
    N2 <--> R

2. Start Redis

docker run --rm -d --name redis-backplane -p 6379:6379 redis:8-alpine
docker exec redis-backplane redis-cli ping

You should get PONG.

3. Scaffold the project

mkdir ws-scale && cd ws-scale
npm init -y
npm pkg set type=module
npm install ws redis

type: module buys you top-level await, which keeps the Redis connection setup flat instead of nested in an async IIFE.

4. Write the server

Create server.js:

import { WebSocketServer, WebSocket } from 'ws';
import { createClient } from 'redis';

const PORT = Number(process.env.PORT ?? 9081);
const CHANNEL = 'broadcast';

const publisher = createClient({ url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379' });
publisher.on('error', (err) => console.error('redis publisher:', err.message));
await publisher.connect();

// Separate connection for the subscriber: under RESP2 a subscribed client
// can't run other commands, and duplicate() clones the connection options.
const subscriber = publisher.duplicate();
subscriber.on('error', (err) => console.error('redis subscriber:', err.message));
await subscriber.connect();

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

// Anything published to CHANNEL by any instance goes to this instance's sockets.
await subscriber.subscribe(CHANNEL, (payload) => {
  for (const client of wss.clients) {
    if (client.readyState === WebSocket.OPEN) client.send(payload);
  }
});

wss.on('connection', (socket) => {
  socket.on('error', console.error);
  socket.on('message', (data) => {
    // Publish instead of broadcasting locally, so there's exactly one
    // delivery path and no dedup logic for the sender's own instance.
    publisher.publish(CHANNEL, JSON.stringify({ from: PORT, text: data.toString() }));
  });
});

console.log(`instance ${PORT} listening, subscribed to "${CHANNEL}"`);

The from field is debug scaffolding: it tells you which process published a given message, which is exactly what you need to prove the backplane works. Strip it in production.

node-redis restores subscriptions automatically after a reconnect, so you don't write that recovery path yourself.

5. Run two instances

In two terminals:

PORT=9081 node server.js
PORT=9082 node server.js

Each prints instance <port> listening, subscribed to "broadcast".

6. Put nginx in front

Create nginx.conf next to server.js:

events {}

http {
  map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
  }

  upstream ws_nodes {
    server host.docker.internal:9081;
    server host.docker.internal:9082;
  }

  server {
    listen 9080;

    location / {
      proxy_pass http://ws_nodes;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
      proxy_set_header Host $host;
      proxy_read_timeout 3600s;
    }
  }
}

Upgrade and Connection are hop-by-hop headers, so nginx drops them unless you re-set them explicitly and the handshake never reaches your backend as an upgrade. proxy_read_timeout is raised because nginx closes a proxied connection after 60 idle seconds by default. You no longer need proxy_http_version 1.1: 1.1 became the default in nginx 1.29.7.

docker run --rm -d --name ws-lb -p 9080:9080 \
  --add-host=host.docker.internal:host-gateway \
  -v "$PWD/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:alpine

--add-host is what makes host.docker.internal resolve on Linux; on Docker Desktop it's redundant but harmless. nginx round-robins by default, so alternating connections land on alternating instances.

Verify it works

Both instances should be subscribed:

docker exec redis-backplane redis-cli pubsub numsub broadcast
broadcast
2

Now open two more terminals and connect both clients through the load balancer:

npx wscat -c ws://localhost:9080

Type hello from A into the first one. Both terminals print the same payload (the from value is whichever instance nginx handed that client, so yours may read 9082):

Connected (press CTRL+C to quit)
> hello from A
< {"from":9081,"text":"hello from A"}
>

Type into the second client and watch the from value flip to the other port. That difference is the proof: the two clients are attached to different Node processes, and the message crossed Redis to get from one to the other.

Tear down with docker rm -f ws-lb redis-backplane and Ctrl-C on both Node processes.

Troubleshooting

redis publisher: connect ECONNREFUSED 127.0.0.1:6379, repeating every second. Redis isn't reachable. Check docker ps and re-run step 2. node-redis retries indefinitely, so the line repeats until the server comes back; the loop stopping is your success signal.

Error: listen EADDRINUSE: address already in use :::9081. Something already holds the port, usually an instance you forgot to kill. Find it with lsof -i :9081, stop it, or pass a different PORT.

error: Unexpected server response: 426 from wscat. The request reached a backend without upgrade headers, so ws rejected it with 426 Upgrade Required. Your proxy_set_header Upgrade / Connection lines are missing or misspelled. Fix nginx.conf and restart the container.

nginx: [emerg] host not found in upstream "host.docker.internal:9081" and the container exits immediately. nginx resolves upstream hostnames at startup and refuses to boot when one doesn't resolve. You dropped --add-host=host.docker.internal:host-gateway from the docker run.

Messages reach only one instance's clients. That instance's subscriber never connected. Re-run the pubsub numsub check; a count below your instance count points at the process to inspect.

Next steps

Plain pub/sub is fire-and-forget: a client that's offline when a message publishes misses it permanently. Keep pub/sub as the live path and back it with Redis Streams if you need replay on reconnect.

From here, split the single channel into per-room channels (room:{id}) and subscribe on demand, add ping/pong heartbeats so dead sockets get reaped, and move to sharded pub/sub (SSUBSCRIBE / SPUBLISH, exposed as sSubscribe / sPublish in node-redis) once you outgrow a single Redis node. Sharded channels hit one shard instead of every node in the cluster.

Sources & further reading

  1. node-redis Pub/Sub documentation — github.com
  2. WebSocket proxying — nginx.org
  3. Module ngx_http_proxy_module — nginx.org
  4. ws: a Node.js WebSocket library — github.com
  5. Redis Pub/Sub — redis.io
  6. Node.js Releases — nodejs.org
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading