WebSockets and Gateways
- nestjs
- websockets
- socket-io
- redis
- node-js
- system-design
- real-time
- backend-development
- typescript
- distributed-systems
Last time, I covered scheduling and cron tasks: work the server initiates on its own timeline, with no client waiting on the other end. This article flips that model around. WebSockets are for the opposite case: a client that stays connected and expects the server to push data the moment something changes, not thirty seconds later on the next poll.
This is part of the NestJS Production Guide series, and gateways sit in an interesting spot in that guide. Everything up to this point has been request-response: a client asks, the server answers, the connection closes. WebSockets keep the connection open. That changes how you think about authentication, scaling, and state.
I will cover how NestJS gateways work under the hood, how to structure rooms and namespaces for a multi-tenant SaaS, and the Redis adapter you need the moment you run more than one instance. I'll also get into the failure modes that only show up once real users are connected for hours at a time.
Why WebSockets, and why not just poll
Before reaching for a gateway, it is worth asking whether the feature actually needs a persistent connection. Most "real-time" requirements in a SaaS product don't. A dashboard that refreshes every 30 seconds, a notification badge that updates on page load, a report that finishes in the background and shows up next time the user checks: all of these are fine with polling or with the Server-Sent Events approach I'll cover in the next article.
WebSockets earn their complexity when the interaction is genuinely bidirectional and latency matters in both directions: chat, collaborative editing, live cursors, multiplayer state, or a trading dashboard where users act on price changes within a second or two. If your feature is server-to-client only, look at SSE first. It's simpler to implement, works over plain HTTP, survives corporate proxies better, and doesn't need a separate scaling story. I've watched teams reach for Socket.IO because "we need real-time" and end up building infrastructure for a feature that only ever pushed data one way.
The tradeoff you're accepting with WebSockets is operational complexity. Every open connection holds a slot on your server process. Your load balancer needs to support upgrade requests and sticky behavior (or a shared pub/sub layer, so it doesn't matter which instance holds the connection). Your authentication model has to work for a long-lived connection instead of a per-request token check. None of this is exotic, but it's not free either, and it's the kind of thing better decided deliberately than inherited from a starter template.
Building a gateway
NestJS wraps Socket.IO (or a raw ws adapter, if you want to skip the extra framing Socket.IO adds) behind the @WebSocketGateway() decorator, using the same dependency injection and decorator style as controllers. That consistency is the main reason to use the NestJS abstraction instead of wiring Socket.IO directly: your gateway can inject the same services, guards, and pipes the rest of your app already has.
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
// src/notifications/notifications.gateway.ts
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common';
import { WsJwtGuard } from './ws-jwt.guard';
import { NotificationsService } from './notifications.service';
@WebSocketGateway({
namespace: '/notifications',
cors: {
origin: process.env.APP_ORIGIN,
credentials: true,
},
})
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(NotificationsGateway.name);
@WebSocketServer()
server: Server;
constructor(private readonly notificationsService: NotificationsService) {}
async handleConnection(client: Socket) {
const tenantId = client.handshake.auth?.tenantId as string | undefined;
const userId = client.handshake.auth?.userId as string | undefined;
if (!tenantId || !userId) {
client.disconnect(true);
return;
}
await client.join(`tenant:${tenantId}`);
await client.join(`user:${userId}`);
this.logger.log(`Client connected: user=${userId} tenant=${tenantId}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('notifications:ack')
async handleAck(
@ConnectedSocket() client: Socket,
@MessageBody() body: { notificationId: string },
) {
await this.notificationsService.markAsRead(body.notificationId);
return { status: 'ok' };
}
emitToUser(userId: string, event: string, payload: unknown) {
this.server.to(`user:${userId}`).emit(event, payload);
}
emitToTenant(tenantId: string, event: string, payload: unknown) {
this.server.to(`tenant:${tenantId}`).emit(event, payload);
}
}
A few things in that gateway are worth calling out because they're easy to get wrong on a first pass.
Authenticate at handshake, not on every message. Reading client.handshake.auth in handleConnection and disconnecting immediately if it's missing is much cheaper than validating a JWT on every incoming event. Do the expensive check once, attach whatever identity data you need to the socket, and trust it for the life of the connection. If you need per-message authorization for sensitive actions (an admin-only broadcast, for instance), that's what the WsJwtGuard on handleAck is for: a lightweight guard that re-checks a specific claim, not a full re-authentication.
Rooms, not broadcast-to-everyone. Joining tenant:${tenantId} and user:${userId} rooms on connect means you can target a message precisely later, from anywhere in your app, without keeping your own map of socket IDs to users. emitToUser and emitToTenant become simple, reusable methods that other services can call by injecting the gateway.
The token itself shouldn't ride in the URL. Socket.IO's handshake.auth object is sent as part of the initial connection payload, not appended as a query string, so it doesn't end up logged by proxies or load balancers the way a ?token=... query param would. If you're on a raw ws gateway without Socket.IO's handshake object, pass the token as a subprotocol or in a connection-time message instead of the URL.
Scaling beyond one instance with the Redis adapter
The gateway above works cleanly until you run two instances behind a load balancer. If a client connects to instance A and joins tenant:42, and your emitToTenant call happens to execute on instance B, that message goes nowhere. server.to(room).emit() only reaches sockets connected to the same Node process by default; Socket.IO has no idea another instance exists.
The fix is the Redis adapter, which turns Socket.IO's room and broadcast mechanism into a pub/sub problem instead of an in-memory one. Every instance publishes to Redis, and every instance (including the one that published) subscribes and re-emits to its own local sockets that match the room.
npm install @socket.io/redis-adapter ioredis
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { AppModule } from './app.module';
class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter>;
async connectToRedis(): Promise<void> {
const pubClient = new Redis(process.env.REDIS_URL);
const subClient = pubClient.duplicate();
this.adapterConstructor = createAdapter(pubClient, subClient);
}
createIOServer(port: number, options?: any) {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Once this is wired in, emitToTenant works correctly regardless of which instance the connected clients or the emitting code happen to be on. This is the same Redis instance you're likely already running for caching and BullMQ. It's reasonable to reuse it rather than standing up a dedicated pub/sub cluster, at least until connection volume is high enough that you're measuring Redis as a bottleneck rather than assuming it might be.
One thing this doesn't solve is sticky sessions at the load balancer. Socket.IO's default transport negotiation does an HTTP long-polling handshake before (optionally) upgrading to a real WebSocket, and that negotiation needs to land on the same server instance across a few requests. Configure your load balancer for session affinity (an ALB target group with sticky cookies, or an Nginx ip_hash directive) or force the client to skip straight to the WebSocket transport and avoid the handshake dance entirely. Skipping the transport negotiation is often the simpler fix, since it removes a class of load-balancer configuration you'd otherwise have to get right and keep right.
Handling reconnection and message delivery honestly
A client that had its laptop closed for ten minutes, or lost wifi on a train, will reconnect, and it needs a way to reconcile whatever happened while it was gone. This is the part of building a real-time feature that's easy to skip in a demo and expensive to bolt on later.
Socket.IO reconnects automatically on the client side and fires a fresh handleConnection on the server. What it does not do is redeliver messages sent while the client was disconnected: those are simply gone, because they were emitted to a room the client was, at that moment, not part of. If a "you have a new notification" event fires while a user's connection is down, and your only source of truth is the WebSocket stream, that notification never arrives.
The fix is to treat the WebSocket as a low-latency notification channel, not the system of record. Persist the underlying event (in this example, the notification row) to PostgreSQL first, then emit over the socket as a best-effort push. On reconnect, have the client fetch anything it might have missed through a normal REST call, filtered by a timestamp or cursor it kept from before the disconnect.
// src/notifications/notifications.service.ts
async createAndPush(userId: string, payload: CreateNotificationDto) {
const notification = await this.notificationsRepository.save({
userId,
...payload,
createdAt: new Date(),
});
this.notificationsGateway.emitToUser(userId, 'notification:new', notification);
return notification;
}
The socket emit here is a convenience, not a guarantee. The client that's online gets it instantly; the client that reconnects a minute later gets the same data through its normal "fetch notifications since my last known cursor" call. This pattern (write durably, push optimistically) is the same shape as the outbox pattern I covered earlier in this series, just applied to a WebSocket fan-out instead of a message broker.
Production pitfalls worth planning for
A handful of issues show up specifically once a WebSocket feature has real users connected for extended periods, rather than a handful of connections in a demo.
Memory growth from stale connections. If a client's network drops without a clean TCP close (a phone going into airplane mode is the classic case), the server can hold a socket open long past the point it's useful, waiting for a ping timeout. Configure pingInterval and pingTimeout deliberately rather than trusting the defaults, and watch connection counts in your monitoring alongside memory: a slow climb in open sockets that never drops is usually this problem, not a leak in your own code.
CORS and origin checks that only work in development. The cors: { origin: process.env.APP_ORIGIN } in the gateway above needs to be an actual allowlist in production, not *. It's easy to leave a permissive CORS config in place because the gateway "just works" in local testing against localhost, and then discover in a security review that any origin could open a socket and listen on a room, if room membership isn't also checked server-side.
Authorization checked once and never again. Joining tenant:${tenantId} at connect time is fine, but if a user's role or tenant membership changes mid-session (they're removed from an organization, their plan is downgraded, they're suspended), the existing socket connection has no idea. Decide explicitly whether you need to actively disconnect sockets on a permission change, typically by publishing a "revoke" event to a per-user Redis channel that any instance holding that connection can subscribe to and act on.
Testing gateways in CI. Gateways don't fit neatly into the request-response testing patterns from the integration and E2E testing article. You generally want a real Socket.IO client instance connecting to your Nest app in a test harness, asserting on emitted events with a short timeout, rather than trying to unit-test the gateway class in isolation. The connection lifecycle itself (handshake, room join, disconnect cleanup) is exactly the part that's easy to get wrong and hard to catch without an integration-style test.
Key takeaways
- Reach for WebSockets only when the interaction is genuinely bidirectional and low-latency in both directions; server-to-client-only features are usually a better fit for Server-Sent Events.
- NestJS gateways use the same decorator and DI style as controllers, which lets you inject existing services and reuse guards instead of building a parallel Socket.IO app.
- Authenticate once at handshake and join rooms scoped to tenant and user; that gives you precise, reusable targeting for emits without maintaining your own socket-to-user map.
- The Redis adapter is not optional once you run more than one instance; without it, `emit` only reaches sockets connected to the same process.
- Treat the socket as a delivery convenience, not a guarantee. Persist state durably first and let clients reconcile missed events on reconnect through a normal API call.
- Plan for stale connections, origin checks, and mid-session permission changes before they show up as a support ticket, not after.
Frequently asked questions
Do I need Socket.IO, or can I use the raw `ws` library with NestJS?
NestJS supports both through its adapter system. Socket.IO adds automatic reconnection, room support, and a fallback to long-polling for clients that can't establish a WebSocket, at the cost of a slightly heavier protocol. Raw `ws` is lighter and gives you full control, but you'll rebuild reconnection and room logic yourself. For most SaaS features, the convenience of Socket.IO's built-in rooms and reconnection outweighs the extra payload size.
How do I authenticate a WebSocket connection if I can't send an Authorization header?
Pass the JWT through the Socket.IO handshake's `auth` object (as shown in the gateway example) rather than a query string, since query strings are more likely to be logged by intermediate proxies. Verify the token in `handleConnection` and disconnect immediately if it's missing or invalid, rather than allowing the connection and gating individual messages behind a guard.
Why do my WebSocket events work locally but not after deploying to multiple instances?
This is almost always the missing Redis adapter. Without it, Socket.IO's room and broadcast mechanism is scoped to a single Node process, so an emit issued on one instance never reaches a socket connected to another. Add the `@socket.io/redis-adapter` as shown above, and confirm your load balancer either supports sticky sessions or that you're forcing clients to skip the long-polling handshake.
Should I use WebSockets for chat features in a multi-tenant SaaS?
Yes, chat is one of the clearer cases where the bidirectional, low-latency nature of WebSockets earns the complexity. Scope rooms per conversation rather than per tenant so message fan-out stays precise, and persist every message to PostgreSQL before or immediately after emitting, so the chat history survives a reconnect and isn't dependent on the socket layer.
How do I test a NestJS gateway without a full browser client?
Use the `socket.io-client` package inside your test suite to open a real connection to your running Nest application, the same way you'd use `supertest` against an HTTP controller. Assert on emitted events with a bounded timeout so a test fails cleanly instead of hanging if an expected event never arrives.
What happens to in-flight messages if I restart a server instance during a deploy?
Any socket connected to that instance drops and the client's reconnection logic kicks in, hitting whichever instance the load balancer routes to next. This is exactly why the socket should never be your only source of truth: a rolling deploy will disconnect clients briefly, and your reconnect-and-reconcile logic (fetch anything missed since the last known cursor) is what keeps that invisible to the user.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.