Scaling Past One Process

An in-memory client list only knows its own process, so broadcasts have to travel over a shared bus like Redis pub/sub.

Syntaxconst sub = client.duplicate(); await sub.connect(); await sub.subscribe('room:42', (message) => sendToLocalClients(message));

A single process keeps its client list in memory: a Set of open responses, or a Map from user id to socket. That works perfectly until you run a second copy.

A replica is another copy of the same program, running as its own process, usually on another machine. A load balancer, the component that sits in front and spreads incoming connections across them, decides which replica a given client lands on. Each process has its own memory. So each replica knows only the clients connected to it. Looping over your local Set now reaches those clients and nobody else: with three replicas, roughly two thirds of your users see nothing.

Publish instead of writing

Move the fan-out off the process. All replicas connect to one shared message bus, and Redis pub/sub is the usual choice. A replica with news calls publish. Every replica is subscribed, receives a copy, and writes it to its own local sockets. The client list stays local; only the message travels.

What trips people up

On the older RESP2 protocol a subscribed Redis connection cannot run ordinary commands, so subscribe on client.duplicate() and keep your query client separate. Presence — the list of who is online — has exactly the same problem as the client list, so it belongs in Redis too, not in a variable. And a WebSocket stays on the replica that accepted it, but a fallback that polls over HTTP does not: those requests must be routed back to the same replica, which is what sticky sessions mean.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Make an existing broadcast correct after scaling a service from one process to three.
  • Send a notification to a logged-in user from a webhook handler on a different replica.
  • Keep an accurate online-users list across several replicas.
  • Decide whether a real-time feature needs sticky sessions at the load balancer.

More examples

Broadcast across replicas with Redis pub/sub

The minimum change that makes a working one-process broadcast correct on two or more. Note that nothing about the local Set changes: it is the delivery that moves onto the bus.

Example · javascript
// server.mjs - npm install redis. Top-level await needs ES modules.
import { createClient } from 'redis';

const local = new Set(); // only the sockets attached to THIS replica

const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate(); // a subscriber needs a connection of its own
await pub.connect();
await sub.connect();

// Every replica runs this, so every replica gets every message.
await sub.subscribe('room:42', (message) => {
  for (const ws of local) ws.send(message); // local fan-out
});

// Any replica can be the one that has news to send.
export async function broadcast(text) {
  await pub.publish('room:42', text);
}

A message for one user, wherever they are connected

How a normal request handler, a webhook, or a background job reaches one connected user. Every replica receives the message and only the one holding that socket acts, which is cheap enough until the fan-out itself becomes the cost.

Example · javascript
const sockets = new Map(); // userId -> socket, for users on this replica only

await sub.subscribe('user-events', (message) => {
  const { userId, payload } = JSON.parse(message);
  const ws = sockets.get(userId);
  if (ws) ws.send(JSON.stringify(payload)); // replicas without them do nothing
});

// An HTTP route has no idea which replica holds this user's socket,
// and does not need to know.
async function notifyUser(userId, payload) {
  await pub.publish('user-events', JSON.stringify({ userId, payload }));
}

Presence that survives a replica dying

Any shared state about connections, not just the sockets, has to leave process memory. The expiry is the important part: without it, a replica that is killed leaves its users marked online with nothing left to remove them.

Example · javascript
// Who is online cannot live in a variable either: each replica would
// report only its own share of the users.
const key = (userId) => `online:${userId}`;

function trackPresence(userId) {
  // EX sets an expiry in seconds, so a crashed replica's users drop off
  // by themselves rather than staying online forever.
  pub.set(key(userId), '1', { EX: 90 });

  const refresh = setInterval(() => pub.expire(key(userId), 90), 30000);

  // Returned so the socket's close handler can undo both at once.
  return async () => {
    clearInterval(refresh);
    await pub.del(key(userId));
  };
}

Discussion

  • Be the first to comment on this lesson.