I have watched the same failure play out in real-time features more than once. One server, one room, everything works. You add a second instance behind a load balancer to handle more connections, and half your users stop receiving messages. Nobody changed the room code. Nobody changed the gateway. The system just got bigger, and the thing that worked at one instance quietly stopped working at two.
The cause is simple, and that is what makes it dangerous. Socket.io rooms live in memory by default. Instance A knows its own sockets. Instance B knows its own sockets. Neither knows the other's. When instance A broadcasts to a room, it only talks to the sockets it can see.
This post is the fix I reach for in NestJS: the Redis adapter, the sticky-session rule that makes it actually work, and a room pattern that stops you from rebuilding the same in-memory trap in a new place. It is written for a NestJS app that already has a working gateway on one instance and now needs a second one.
Why a second instance breaks your rooms
A single Socket.io server holds every room, every socket, and every membership in its own process memory, so a broadcast to a room is a local loop over local sockets. Add a second instance and that loop still only covers local sockets, which means a message sent from instance A never reaches a room member connected to instance B. Nothing errors, no exception is thrown, and the sender's own test client often passes because it happens to be connected to the sending instance.
That silent half-delivery is the signature of this bug. If your chat, live feed, or collaboration room works for some users and not others, and the split lines up with which instance they landed on, you are almost certainly looking at in-memory rooms behind a load balancer rather than a bug in your room logic.
The Redis adapter, in one picture
The Redis adapter replaces the in-memory room store with a shared channel that every instance listens to, so a broadcast becomes a publish step plus a local delivery step on each instance. When instance A emits to a room, it publishes that event to Redis; instance B receives it through its subscription and delivers it to its own local sockets in that room, and instance A does the same for its locals. Your gateway code does not change shape: server.to(room).emit(...) keeps working, and the adapter changes where that call reaches.
Two details in that picture matter for everything that follows. First, the adapter moves messages between servers, but it does not move your application state between servers. A map you built in instance A's memory is still invisible to instance B. Second, Redis becomes part of your real-time path, so a Redis outage is now a real-time outage unless you have planned for it. I cover both failure modes below, because the adapter solves the broadcast problem and creates two smaller problems you need to handle on purpose.
Wire it in NestJS
Start from the documented NestJS shape and do not improvise the wiring. Install the three packages, extend IoAdapter, connect two Redis clients, and register the adapter before the app starts listening. The two-client split is not optional decoration: a Redis connection in subscriber mode cannot issue regular commands, so the publishing client and the subscribing client must be separate connections, and duplicate() gives the subscriber the same configuration on its own connection.
npm install redis socket.io @socket.io/redis-adapter // redis-io.adapter.ts import { IoAdapter } from '@nestjs/platform-socket.io'; import { ServerOptions } from 'socket.io'; import { createAdapter } from '@socket.io/redis-adapter'; import { createClient } from 'redis'; export class RedisIoAdapter extends IoAdapter { private adapterConstructor: ReturnType<typeof createAdapter>; async connectToRedis(): Promise<void> { const pubClient = createClient({ url: process.env.REDIS_URL }); const subClient = pubClient.duplicate(); await Promise.all([pubClient.connect(), subClient.connect()]); this.adapterConstructor = createAdapter(pubClient, subClient); } createIOServer(port: number, options?: ServerOptions): any { const server = super.createIOServer(port, options); server.adapter(this.adapterConstructor); return server; } } // main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { RedisIoAdapter } from './redis-io.adapter'; async function bootstrap() { const app = await NestFactory.create(AppModule); const redisIoAdapter = new RedisIoAdapter(app); await redisIoAdapter.connectToRedis(); app.useWebSocketAdapter(redisIoAdapter); await app.listen(3000); } bootstrap(); Three things in that code deserve a second look before you ship it. Connect both clients before the app listens, or early broadcasts can run before the adapter is ready. Read the Redis URL from the environment, never hardcode localhost, or your staging and production setups will diverge in exactly the way that wastes a debugging afternoon. And keep the adapter construction in one place: every gateway in the app shares this adapter through useWebSocketAdapter, so a second copy of the wiring in another module is a second chance to drift. This is the same production NestJS stack I describe on my hiring page, where Socket.io and BullMQ on Redis are the real-time and background pair I ship with.
Sticky sessions: the step everyone skips
The Redis adapter moves broadcasts between instances, but it does not fix the handshake, and this is the step that sinks most first attempts at scaling Socket.io. A Socket.io client starts with HTTP long polling and then upgrades to a WebSocket connection, and those polling requests must reach the same instance that owns the session. If your load balancer round-robins a polling request to a different instance, that instance does not know the session, and the client gets errors and reconnect loops instead of a working socket.
The NestJS docs state the rule plainly: with multiple load-balanced instances you must either disable polling by setting transports: ['websocket'] in the client configuration, or enable cookie-based routing (sticky sessions) in your load balancer, because Redis alone is not enough. Pick one deliberately:
| Approach | What it costs you | When I pick it |
|---|---|---|
| WebSocket-only transport | You lose the polling fallback, so clients behind strict proxies that block WebSocket upgrades cannot connect at all | Internal tools, controlled clients, or a first scaled deployment where you can measure how many clients actually need polling |
| Cookie-based sticky sessions | Your load balancer must support it and you must test it, since a misconfigured cookie rule fails exactly like no rule at all | Any public-facing app, because it keeps the polling fallback working while still pinning a session to one instance |
Do not substitute IP-based hashing and call it done without thinking. Users behind corporate NAT, mobile networks, and shared Wi-Fi can share an IP or change it mid-session, and IP hashing pins them by an address that is not really theirs. A cookie set by the load balancer tracks the session, not the network path, and that is the property you actually need. Whichever option you pick, test it with polling enabled and with two instances running, because a sticky rule that only works in a single-instance test is not a sticky rule.
Address users and tenants with rooms, not in-memory maps
Once broadcasts cross instances, the next trap is addressing. The question stops being "how do I emit to a room" and becomes "how do I reach this one user, or this one tenant's room, when I have no idea which instance holds their socket". The answer is to stop asking which instance holds them. Address the room, and let the adapter find the instance.
The pattern I use has two rules. First, every socket joins a room named for its user immediately after authentication, before it joins anything else. Second, every shared room is named for the thing it belongs to, not for a server or a process, so a tenant room looks like tenant:9:project:41 and a user room looks like user:42. A service that needs to notify one user then emits to user:42 from any instance, and the adapter delivers it to whichever instance holds that socket.
// rooms.gateway.ts import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, MessageBody, ConnectedSocket, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; @WebSocketGateway({ cors: { origin: process.env.CLIENT_ORIGIN } }) export class RoomsGateway implements OnGatewayConnection { @WebSocketServer() server: Server; async handleConnection(client: Socket) { // Authenticate first. Only join rooms for a verified user id. const userId = client.data.userId as string; await client.join(`user:${userId}`); } @SubscribeMessage('join-project') async onJoinProject( @MessageBody() projectId: string, @ConnectedSocket() client: Socket, ) { // Check membership in your database here, then join. await client.join(`project:${projectId}`); return { joined: projectId }; } async notifyProject(projectId: string, payload: unknown) { // Callable from any instance. The adapter routes it. this.server.to(`project:${projectId}`).emit('project-updated', payload); } } That gateway sketch leaves two things deliberately out, and both are worth naming. Authentication is shown as a placeholder because token validation belongs in your own guard or handshake check, not in a copy-pasted snippet, and a socket that joins a room before authentication is a socket that can listen to a room it should never see. Membership checks are also a placeholder: joining project:41 must be gated on a database check that this user belongs to project 41, because a room name is not a permission. Validate the join payload before trusting it too; I use Zod at every boundary, as I wrote up in Zod as a contract. The adapter will faithfully deliver messages to anyone in the room, so the join step is where your access control lives.
One more habit pays off here. When you need to know who is actually connected, use the adapter's own tools, such as fetchSockets, instead of a local map. A local map answers "who is connected to this instance". The adapter answers "who is connected to this system", and at two or more instances only the second question is useful.
Pub/sub vs Redis Streams: my pick
Socket.io now ships two Redis-backed adapters, and the choice between them is a real decision rather than a version detail. The classic adapter forwards packets through Redis pub/sub. The newer Redis Streams adapter forwards them through a Redis stream, and its docs give it one clear advantage: it handles a temporary disconnection from Redis and resumes the stream without losing packets, where the classic pub/sub adapter does not offer that resume behavior. Both still need sticky sessions, and the Streams docs say so directly, so the new adapter does not remove the handshake work from the previous section.
| Adapter | How messages move | Disconnect behavior | NestJS wiring |
|---|---|---|---|
Classic @socket.io/redis-adapter | Redis pub/sub, two clients | A gap during a Redis outage can lose packets | Documented in the NestJS adapter guide, shown above |
@socket.io/redis-streams-adapter | One Redis stream, one client | Resumes the stream after a temporary disconnect without losing packets | Custom wiring through the same IoAdapter extension point, not the documented NestJS example |
My pick is boring on purpose: start with the classic adapter from the NestJS docs. It is the documented path, it matches the examples other NestJS developers will recognize when they read your code, and its failure mode is one you can plan around with reconnect handling and client-side resync. Move to the Streams adapter when temporary Redis disconnects are a real problem in your deployment, not a theoretical one, and when you have time to verify the custom wiring against your own gateway tests. A newer adapter is not automatically the right adapter for a system that is already working.
Prove it with two instances on your machine
A scaling change that has never been observed across two instances is a guess, and this one is cheap to observe. Run Redis locally, start your NestJS app twice on two ports, connect one client to each instance, and then trigger a broadcast from a service running on only one of them. If both clients receive it, the adapter is working. If only the client on the sending instance receives it, check the adapter registration first, then the Redis connection, then the room name, in that order, because those three cover most wiring mistakes.
Make the test adversarial in one specific way: connect with polling enabled, not websocket-only, and watch what happens when the load balancer is in the path. A setup that passes with a direct connection to one instance and fails behind the balancer has a sticky-session problem, not an adapter problem, and no amount of adapter debugging will fix it. The same test with websocket-only transport passing and polling failing points at the same place. Name the failure before you fix it, or you will fix the wrong layer twice.
Also verify the negative case. Disconnect a client, then broadcast to its room, and confirm nothing crashes and no stale delivery appears when it reconnects. Room membership is tied to the socket, not the user, so a reconnected client must rejoin its rooms after authentication. If your client code assumes rooms survive a reconnect, your users will silently stop receiving messages after every network drop, and that failure looks exactly like the original scaling bug.
When Redis is down, and other failure modes
Putting Redis in the middle of your real-time path changes what an outage means, so plan for it before it happens. The classic adapter has no stream to resume from, so messages published while Redis is unreachable do not wait politely for it to come back. Your clients need a resync path for exactly this case: on reconnect, the client asks for current state over HTTP or replays its room joins, and the server answers with the latest state rather than assuming the client saw every event. That resync endpoint is not extra polish. It is the thing that turns a Redis blip into a brief catch-up instead of a permanently stale screen.
Three more failure modes deserve a line each. First, keep payloads small. Every broadcast is published to Redis and then fanned out to every instance, so a large payload is multiplied by your instance count before it reaches a single client. Send ids and small deltas, and let clients fetch full objects over HTTP when they need them. Second, treat Redis as trusted internal infrastructure. The Socket.io docs warn that adapter messages are not signed or encrypted by the adapter, so do not expose Redis to untrusted networks, use authentication and TLS where your setup supports them, and never put long-lived secrets in event payloads. Third, watch the fan-out itself: a room with thousands of members across many instances turns one emit into thousands of deliveries, and that is a product decision as much as an infrastructure one. If a room grows that large, split it by topic or shard it by tenant before you scale the instance count to compensate.
The tools I would actually use
| Tool | What it is for | Note |
|---|---|---|
| Redis | The shared channel between Socket.io instances | Run it as managed infrastructure in production, not as a container you forget about on a single host |
@socket.io/redis-adapter | The documented NestJS scaling path | The two-client setup above; keep its version aligned with your Socket.io version |
@socket.io/redis-streams-adapter | The disconnect-resilient alternative | Evaluate it when Redis gaps become a measured problem, not before |
| A load balancer with cookie stickiness | Pinning polling sessions to one instance | Test it with polling enabled; IP hashing is a weaker substitute |
| Docker Compose | Running Redis plus two app instances locally | The cheapest way to observe the two-instance behavior before deploying it |
{/* MONETIZATION NOTE: the tools table above is affiliate-ready. Swap the Redis and load-balancer rows for affiliate links only when a real program is joined; no affiliate claim is made here. */}
My verdict: what I would pick, and why
If your NestJS app has one instance today and needs two tomorrow, I would pick the classic Redis adapter from the NestJS docs, cookie-based sticky sessions at the load balancer, and room-per-user addressing from day one. That combination is documented, it is easy for the next developer to reason about, and it fixes the actual failure: broadcasts that only reach local sockets.
I would not pick the Streams adapter first, even though its disconnect behavior is better, because the documented path matters more than the newer feature when you are changing a live real-time system. I would not pick websocket-only transport for a public app, because giving up the polling fallback trades one visible failure for a quieter one on restrictive networks. And I would not ship any of it without the two-instance test above, because a scaling setup you have not watched fail and recover is a setup you do not understand yet.
The answer flips in two cases. If you already measure packet loss during Redis disconnects and users notice stale screens after every blip, the Streams adapter earns its custom wiring. If your clients are all internal and controlled, websocket-only transport is simpler than sticky sessions and the tradeoff disappears. Outside those cases, the boring documented combination is the right default, and boring is a compliment when the system in question is the one your users stare at all day.
If you are building a multi-tenant real-time system and want a second pair of eyes on the room design before it ships, that is the kind of production NestJS work I do. Reach me through the contact section on the homepage with the room model, the instance count, and the failure you are seeing, and we can walk through it in a first call.
FAQ
Why do Socket.io rooms break when I add a second NestJS instance?
By default each Socket.io server keeps its rooms in its own memory. A broadcast from instance A only reaches sockets connected to A, so a user on instance B never sees it. The Redis adapter fixes this by forwarding broadcasts through Redis, so every instance delivers the message to its own local sockets in that room.
Do I still need sticky sessions if I use the Redis adapter?
Yes. The NestJS docs warn about this directly: Redis alone is not enough. Socket.io starts with HTTP long polling and then upgrades to WebSocket, and the polling requests must reach the same instance that owns the session. Either force websocket-only transport or turn on cookie-based routing at your load balancer.
Why does the Redis adapter need two Redis clients?
A Redis connection in subscriber mode cannot issue regular commands, so one connection cannot both publish and subscribe. The standard setup creates a publishing client and then duplicates it for subscribing. That split is in the NestJS docs example and it is the shape to copy, not a detail to simplify away.
Should I use the classic Redis adapter or the Redis Streams adapter?
Start with the classic Redis adapter if you are following the NestJS docs, because that is the documented NestJS path. Consider the Redis Streams adapter when temporary Redis disconnects matter to you, because it resumes the stream without losing packets where the classic pub/sub adapter does not. Either way you still need sticky sessions.
How do I send a message to one user when I do not know which instance they are on?
Put every socket in a room named for its user, such as user:42, right after authentication. Then any instance can emit to that room and the adapter routes it to the instance holding that socket. Never keep a socketId to userId map in one instance's memory and expect other instances to read it.
What breaks first when a Redis-backed Socket.io setup grows?
Usually one of three things: the load balancer sends polling requests to the wrong instance, one instance holds state the others cannot see, or a broadcast payload grows until Redis fan-out becomes the bottleneck. Fix them in that order: sticky sessions first, room-based addressing second, and smaller payloads plus fewer broadcast rooms third.


