Beyond REST: Adding Real-Time Interactivity with NestJS WebSockets

For decades, the request-response model of REST has been the backbone of the web. But what happens when the server needs to initiate a conversation? Discover how NestJS WebSockets enable real-time interactivity.


Beyond REST: Adding Real-Time Interactivity with NestJS WebSockets

For decades, the request-response model of REST has been the backbone of the web. A client asks for data, and the server responds. It’s simple, stateless, and incredibly robust. But what happens when the server needs to initiate a conversation? How do you notify a user that a long-running job has finished, or push live updates to a dashboard without the client having to constantly ask, “Are we there yet?”

This is where WebSockets come in, providing a persistent, two-way communication channel between the client and server. While implementing them from scratch can be complex, the @nestjs/websockets package gives us a powerful, decorator-based abstraction that makes building real-time features elegant and straightforward.

Let’s explore how to build a simple, user-aware notification system using a NestJS WebSocket Gateway.

The Foundation: The WebSocket Gateway

In NestJS, the hub for all WebSocket communication is a “Gateway.” It’s a class adorned with the @WebSocketGateway() decorator, which tells NestJS to treat it as a provider that can listen for and send real-time messages.

A basic gateway implements lifecycle hooks to manage server and client state.

import {
  OnGatewayInit,
  OnGatewayConnection,
  OnGatewayDisconnect,
  WebSocketGateway,
  WebSocketServer,
} from "@nestjs/websockets";
import { Server, Socket } from "socket.io";

@WebSocketGateway()
export class EventsGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  // An instance of the underlying socket.io server
  @WebSocketServer() server: Server;

  // A logger for our gateway
  private readonly logger = new Logger(EventsGateway.name);

  afterInit(server: Server) {
    this.logger.log("WebSocket Gateway Initialized");
  }

  handleConnection(client: Socket, ...args: any[]) {
    this.logger.log(`Client connected: ${client.id}`);
  }

  handleDisconnect(client: Socket) {
    this.logger.log(`Client disconnected: ${client.id}`);
  }
}

This simple structure already gives us a powerful starting point. NestJS handles the boilerplate of setting up the Socket.io server. We just need to implement the handleConnection and handleDisconnect methods to manage our clients.

The Problem: How to Message a Specific User?

By default, socket.io identifies clients by a random, temporary client.id. This is fine if you only ever want to broadcast messages to everyone. But what if you need to send a notification to a specific user? When a background job for “user-A” is complete, you don’t want to notify “user-B” and “user-C”.

You need a way to map a persistent identifier, like a userId, to a transient socket connection.

The Solution: A User-Socket Map

We can solve this by creating a simple in-memory Map inside our gateway. This map will store our application’s userId as the key and the active Socket object as the value.

To populate this map, we’ll require the client to provide a userId during the initial connection handshake.

Client-Side Connection:

// A simple client-side implementation
import { io } from "socket.io-client";

const userId = "user_123"; // This would be the actual logged-in user's ID

const socket = io("http://localhost:3000", {
  query: {
    userId: userId,
  },
});

socket.on("connect", () => {
  console.log("Connected to WebSocket server!");
});

// Listen for a custom event, e.g., 'job_completed'
socket.on("job_completed", (data) => {
  console.log("A job was completed!", data);
  // Show a toast notification, update the UI, etc.
});

Now, we can update our gateway to store this mapping when a client connects and remove it when they disconnect.

Updated Gateway:

@WebSocketGateway()
export class EventsGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer() server: Server;
  private readonly logger = new Logger(EventsGateway.name);

  // Map to store userId -> Socket mapping
  private clientConnections: Map<string, Socket> = new Map();

  afterInit(server: Server) {
    this.logger.log("Initialized");
  }

  handleConnection(client: Socket) {
    // Extract userId from the handshake query
    const userId = client.handshake.query.userId as string;
    if (!userId) {
      // If no userId, disconnect them. In a real app, you'd handle this more gracefully.
      client.disconnect();
      return;
    }

    // Store the mapping
    this.clientConnections.set(userId, client);

    this.logger.log(`Client connected: ${client.id}, UserID: ${userId}`);
    this.logger.log(`Total connected clients: ${this.clientConnections.size}`);
  }

  handleDisconnect(client: Socket) {
    const userId = client.handshake.query.userId as string;

    // Remove the mapping on disconnect
    if (userId) {
      this.clientConnections.delete(userId);
    }
    this.logger.log(`Client disconnected: ${client.id}, UserID: ${userId}`);
  }
}

Sending Targeted Messages

With our clientConnections map in place, sending a message to a specific user becomes trivial. We can create a helper method inside our gateway that other services in our NestJS application can call.

// Inside the EventsGateway class

interface EventBody {
  userId: string;
  eventType: string;
  payload: Record<string, any>;
}

public messageUser(body: EventBody): void {
  // Find the specific socket for the target user
  const userSocket = this.clientConnections.get(body.userId);

  if (userSocket) {
    // Emit the event only to that client
    userSocket.emit(body.eventType, body.payload);
    this.logger.log(`Sent event '${body.eventType}' to user ${body.userId}`);
  } else {
    this.logger.warn(`Could not find connected client for user ${body.userId}`);
  }
}

public messageAll(eventType: string, payload: Record<string, any>): void {
    // This uses the built-in broadcast capabilities
    this.server.emit(eventType, payload);
    this.logger.log(`Broadcasted event '${eventType}' to all clients.`);
}

Now, any other service in our application can inject the EventsGateway and use it to send real-time notifications without needing to know anything about the underlying WebSocket implementation.

// Example usage in another service
@Injectable()
export class SomeBackgroundJobService {
  constructor(private readonly eventsGateway: EventsGateway) {}

  async processJobForUser(userId: string) {
    // ... do some long-running work ...

    // Job is done, notify the specific user
    this.eventsGateway.messageUser({
      userId: userId,
      eventType: "job_completed",
      payload: { message: "Your report is ready for download!" },
    });
  }
}

The Payoff: A Clean, Testable Real-Time Layer

By building a user-aware WebSocket gateway, you create a clean separation of concerns. Your core business logic in other services doesn’t need to manage sockets or connections. It just needs to call a simple method on the EventsGateway provider.

This approach is:

  • Decoupled: Your services interact with a clean EventsGateway interface, not directly with socket.io.
  • User-Centric: It’s built around the concept of your application’s users, not abstract socket IDs.
  • Testable: You can easily mock the EventsGateway in your unit tests to verify that your services are calling it with the correct data.

This simple pattern provides a robust foundation for adding powerful, real-time features to any NestJS application.