# Scaling Out: Multiple Nodes with the Redis Adapter

> Run Relay on many servers: share broadcasts through the Socket.IO Redis adapter, configure sticky sessions, Nginx and ALB correctly, and understand the real cost of fan-out.

- Author: Syed Wajeeh Haider
- Published: Sep 28, 2026
- Series: Real-Time Chat at Scale, part 7 of 10
- Category: Architecture
- Tags: Socket.IO, Redis, Load Balancing, Nginx, AWS
- Reading time: 8 min
- URL: https://syedwajeehhaider.com/scaling-websockets-redis-adapter

Part 6 gave Relay presence and typing indicators backed by Redis. Presence already works across servers because it lives in Redis, but messages do not. Every broadcast we have written so far, `io.to(room).emit()`, only reaches sockets connected to the process that called it. That is invisible with one server and a guaranteed outage with two. This part fixes it and walks through everything between the client and your Node processes that has to understand long-lived connections.

- **1**: Line to add the Redis adapter
- **60s**: Default AWS ALB idle timeout
- **25s**: Default Socket.IO ping interval

## Why a second node breaks broadcast

Socket.IO keeps rooms in memory, in an object called the adapter. When Alice on node A sends a message to `conv:42`, node A looks up its local adapter, finds the sockets it holds for that room and writes to them. Bob, whose socket happens to be on node B, is in `conv:42` too, but only in node B's memory. Node A has no idea he exists. Alice sees her message delivered; Bob sees nothing until he refreshes and catch-up from part 5 kicks in.

The fix is to swap the in-memory adapter for one that forwards broadcasts to every other node. Each node still delivers to its own local sockets, and a shared channel carries the broadcast to the rest.

## The Redis adapter

```bash
npm install @socket.io/redis-adapter ioredis
```

*server/src/index.ts: share broadcasts through Redis pub/sub*

```ts
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';

const pubClient = new Redis(process.env.REDIS_URL!);
const subClient = pubClient.duplicate(); // a subscribed connection can't issue other commands

const httpServer = createServer();
export const io = new Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>(httpServer, {
  adapter: createAdapter(pubClient, subClient),
  transports: ['websocket'], // see the sticky sessions section below
});

httpServer.listen(Number(process.env.PORT ?? 3000));
```

No handler changes. `io.to('conv:42').emit(...)` now delivers locally and publishes the packet to Redis; every other node receives it and delivers to its local members of `conv:42`. Joins and leaves stay local, because each node only needs to know about its own sockets.

With the classic adapter, every node subscribes to the same channel and receives **every** broadcast, even for rooms where it has no members. That is fine for a handful of nodes. As the fleet grows, Redis bandwidth grows with messages times nodes. Redis 7 added sharded pub/sub, and the adapter supports it:

*Sharded pub/sub: nodes only receive broadcasts for rooms they hold*

```ts
import { createClient } from 'redis';
import { createShardedAdapter } from '@socket.io/redis-adapter';

// The sharded adapter is built on node-redis, which supports SSUBSCRIBE / SPUBLISH.
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);

io.adapter(createShardedAdapter(pubClient, subClient));
```

In its dynamic mode, the sharded adapter subscribes each node to a channel per room only while it holds sockets in that room, and with Redis Cluster the channels spread across shards. There is a third option worth knowing: `@socket.io/redis-streams-adapter` uses Redis Streams instead of pub/sub. It takes a single Redis client, tolerates short Redis disconnections without losing packets, and is the one that supports the connection state recovery feature from part 5 across multiple nodes.

> **Which adapter?:** Start with the classic adapter. Move to the sharded adapter when you run many nodes or Redis Cluster, and pick the Streams adapter if you rely on connection state recovery. Whichever you choose, your catch-up by cursor from part 5 is still the real safety net.

## Sticky sessions, and when you can skip them

By default a Socket.IO client connects with HTTP long-polling first and upgrades to WebSocket. Long-polling is a sequence of separate HTTP requests: a handshake that creates a session ID, then repeated GETs to receive and POSTs to send, all carrying that `sid`. The session lives in the memory of the node that created it. If the load balancer sends the second request to a different node, that node has never heard of the session and answers `400 Session ID unknown`, and the client falls into a reconnect loop.

A WebSocket, by contrast, is a single TCP connection. Once the upgrade succeeds, every frame travels on the same connection to the same node, so stickiness is irrelevant. That leaves two options:

- **Keep long-polling** as a fallback for hostile corporate proxies, and enable sticky sessions on the load balancer, by client IP or by cookie.
- **Go WebSocket-only** with `transports: ['websocket']` on both server and client. No stickiness is needed, and the connection is established faster because there is no upgrade dance. In 2026 this is the right default for most apps, including Relay.

*client/src/useChat.ts: WebSocket-only client*

```ts
const socket = io(import.meta.env.VITE_API_URL, {
  transports: ['websocket'],
  auth: (cb) => cb({ token: getAccessToken() }),
});
```

## Nginx in front of Node

Nginx does not proxy WebSockets unless you tell it to. The upgrade relies on the hop-by-hop `Upgrade` and `Connection` headers, which proxies drop by default. You also need HTTP/1.1 to the upstream and a read timeout comfortably longer than the ping interval, or Nginx will cut idle connections at its default 60 seconds.

*/etc/nginx/conf.d/relay.conf*

```nginx
upstream relay_nodes {
  # Only needed if long-polling is enabled; harmless otherwise.
  ip_hash;
  server 10.0.1.11:3000;
  server 10.0.1.12:3000;
  server 10.0.1.13:3000;
  keepalive 64;
}

server {
  listen 443 ssl http2;
  server_name chat.example.com;

  location /socket.io/ {
    proxy_pass http://relay_nodes;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_read_timeout 75s;   # > pingInterval + pingTimeout
    proxy_send_timeout 75s;
  }
}
```

`ip_hash` is the simplest form of stickiness, but many users behind one corporate NAT will all land on the same node. If you need long-polling at scale, prefer cookie-based affinity at the load balancer instead.

## AWS Application Load Balancer

An ALB supports WebSockets natively: the upgrade request is routed like any HTTP request, and the connection then stays pinned to that target. Three settings matter:

- **Idle timeout** defaults to 60 seconds. Socket.IO pings every 25 seconds by default, which keeps the connection busy well within that window. If you raise `pingInterval` to save work (part 8), keep it safely below the idle timeout, or raise the timeout too.
- **Stickiness** on the target group (load balancer generated cookie) is only required if long-polling is enabled.
- **Deregistration delay** controls how long a draining target keeps existing connections. We will use it for graceful deploys in part 10.

## The architecture so far

Here is what Relay looks like once it runs on more than one machine, following a single message from Alice to Bob:

1. **Clients** hold one WebSocket each to `chat.example.com`.
2. **Load balancer** (ALB or Nginx) terminates TLS and routes each new connection to any healthy node.
3. **Node A** receives `message:send` from Alice, checks membership, and inserts the message into **Postgres** with the idempotent insert from part 4.
4. Node A acks Alice, emits `message:new` to its local members of `conv:42`, and publishes the packet to **Redis**.
5. **Node B** receives the packet from Redis and emits it to Bob, who is connected there.
6. Presence keys and typing relays also flow through Redis, so every node sees the same view of who is online.

## Talking across nodes

Some operations need to reach sockets wherever they live. The adapter makes the server-wide API cluster-aware, so the same calls work across every node:

*Cluster-wide operations*

```ts
// Force-logout a user everywhere, e.g. after a password change.
io.in(`user:${userId}`).disconnectSockets(true);

// Make every device of a user join a newly created conversation.
io.in(`user:${userId}`).socketsJoin(`conv:${conversationId}`);

// Inspect sockets across the cluster (returns lightweight RemoteSocket objects).
const sockets = await io.in(`conv:${conversationId}`).fetchSockets();
const onlineMembers = new Set(sockets.map((s) => s.data.user.id));

// Node-to-node messages that never reach clients.
io.serverSideEmit('config:reload', { version: 42 });
io.on('config:reload', ({ version }) => reloadConfig(version));
```

`socketsJoin` deserves attention. When a user is added to a new group, every one of their connected devices, on any node, must join the room, or they will miss messages until they reconnect. Pair it with `socketsLeave` when someone is removed, and never let a client join a room just by asking.

> **Use fetchSockets sparingly:** `fetchSockets()` asks every node and waits for all of them to answer. It is fine for admin actions and occasional checks, but calling it on every message turns each send into a cluster-wide round trip. For hot paths, use the Redis presence data from part 6 instead.

## Emitting from workers

Not every event starts on a socket. A background job that finishes processing an image attachment, or an HTTP webhook from a payment provider, should be able to notify users without holding a WebSocket server itself. The Redis emitter publishes packets in the same format as the adapter, so any process with Redis access can broadcast:

*worker.ts: broadcast without being a Socket.IO server*

```ts
import { Emitter } from '@socket.io/redis-emitter';
import { Redis } from 'ioredis';

const emitter = new Emitter(new Redis(process.env.REDIS_URL!));

export async function onAttachmentReady(message: Message) {
  emitter.to(`conv:${message.conversationId}`).emit('message:new', message);
}
```

## The real cost of fan-out

Adding nodes spreads connections, but it does not reduce the total amount of work a broadcast causes. A message in a room with N members always turns into N socket writes somewhere in the cluster. A 1:1 chat costs two writes. A 10,000-member community channel costs 10,000 writes per message, and a lively channel with ten messages a second means 100,000 writes a second, regardless of how you slice the fleet.

- **Measure room sizes.** A histogram of members per room tells you whether you have a DM product or a broadcast product, and they need different designs.
- **Cap or batch big rooms.** Coalesce updates into one packet every 100 to 250 ms for very large rooms, and drop ephemeral events like typing entirely past a threshold.
- **Keep payloads small.** Every byte is multiplied by the room size. Send IDs and let clients fetch heavy data such as link previews over HTTP, where it can be cached.
- **Watch Redis.** With the classic adapter, the pub/sub channel carries every broadcast to every node. Track Redis network output and CPU as the fleet grows, and move to the sharded adapter before it becomes the bottleneck.

## Key takeaways

- Rooms live in each node's memory; the Redis adapter forwards broadcasts so every node delivers to its local members.
- Use the classic adapter to start, the sharded adapter for large fleets, and the Streams adapter for connection state recovery.
- Sticky sessions are needed only for HTTP long-polling; a WebSocket-only transport removes the requirement.
- Proxy the `Upgrade` and `Connection` headers, and keep ping intervals below every idle timeout on the path.
- Adding nodes spreads connections but not fan-out: a message to N members is always N writes.

Relay now scales horizontally, but each node will still fall over far earlier than it should with default operating system and Node.js settings. Next up, part 8: **Tuning for a Million Concurrent Connections**, where we look at memory per socket, file descriptors, kernel limits and reconnect storms.
