Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Real-time Communication: SignalR vs. WebSockets in NestJS

For .NET engineers who know: SignalR Hubs, groups, Clients.All, Clients.Group(), and the IHubContext<T> service You’ll learn: How NestJS WebSocket Gateways map to SignalR Hubs, where Socket.io diverges from the SignalR mental model, and how to scale real-time connections with a Redis adapter Time: 15-20 min read


The .NET Way (What You Already Know)

SignalR abstracts over WebSockets (and falls back to Server-Sent Events or long-polling automatically). You define a Hub class, and clients call server methods and receive server-pushed events through a strongly-typed contract:

// Server — SignalR Hub
public class ChatHub : Hub
{
    // Clients call this method — like an RPC call
    public async Task SendMessage(string roomId, string message)
    {
        // Push to all clients in the group
        await Clients.Group(roomId).SendAsync("MessageReceived", new
        {
            User = Context.User?.Identity?.Name,
            Message = message,
            Timestamp = DateTimeOffset.UtcNow
        });
    }

    public async Task JoinRoom(string roomId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomId);
        await Clients.Group(roomId).SendAsync("UserJoined", Context.ConnectionId);
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // Cleanup — Hub handles reconnection lifecycle automatically
        await base.OnDisconnectedAsync(exception);
    }
}

// Inject into a controller or service to push from outside the Hub
public class NotificationService
{
    private readonly IHubContext<ChatHub> _hubContext;

    public NotificationService(IHubContext<ChatHub> hubContext)
    {
        _hubContext = hubContext;
    }

    public async Task NotifyRoom(string roomId, string message)
    {
        await _hubContext.Clients.Group(roomId).SendAsync("SystemMessage", message);
    }
}

SignalR gives you: automatic transport negotiation (WebSocket → SSE → long-poll), automatic reconnection on the client, strongly typed hubs, groups, and a Redis backplane for multi-server deployments — all with minimal configuration.


The NestJS Way

NestJS provides WebSocket support through @WebSocketGateway(). The default adapter uses the native Node.js ws library (bare WebSocket), but the most common choice is Socket.io via @nestjs/platform-socket.io. Socket.io brings rooms (equivalent to SignalR Groups), reconnection, and event-based messaging that is conceptually close to SignalR — though the protocol is different and the clients are not interchangeable.

Installation

# Socket.io adapter (most common — closest to SignalR feature parity)
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io

# Redis adapter for scaling (equivalent to SignalR Redis backplane)
npm install @socket.io/redis-adapter ioredis

# Client-side
npm install socket.io-client

Defining a Gateway (the Hub Equivalent)

// chat.gateway.ts
import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common';
import { ChatService } from './chat.service';
import { SendMessageDto } from './dto/send-message.dto';
import { WsJwtGuard } from '../auth/ws-jwt.guard';

// @WebSocketGateway() = SignalR Hub
// cors: { origin: '*' } — tighten this in production
@WebSocketGateway({
  namespace: '/chat',       // Socket.io namespace — like a separate Hub endpoint
  cors: { origin: '*' },
  transports: ['websocket'], // Force WebSocket only — omit to allow polling fallback
})
export class ChatGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer()
  server: Server;            // The Socket.io Server instance — use to push from this class

  private readonly logger = new Logger(ChatGateway.name);

  constructor(private readonly chatService: ChatService) {}

  afterInit(server: Server) {
    this.logger.log('WebSocket Gateway initialized');
  }

  // Equivalent to Hub.OnConnectedAsync()
  handleConnection(client: Socket) {
    const userId = client.handshake.auth.userId as string;
    this.logger.log(`Client connected: ${client.id}, userId: ${userId}`);
    client.data.userId = userId; // Store on the socket for later use
  }

  // Equivalent to Hub.OnDisconnectedAsync()
  handleDisconnect(client: Socket) {
    this.logger.log(`Client disconnected: ${client.id}`);
  }

  // @SubscribeMessage('eventName') = a Hub method that clients call
  // Equivalent to: public async Task SendMessage(string roomId, string message)
  @SubscribeMessage('sendMessage')
  @UseGuards(WsJwtGuard)
  async handleSendMessage(
    @MessageBody() dto: SendMessageDto,
    @ConnectedSocket() client: Socket,
  ): Promise<void> {
    const message = await this.chatService.saveMessage({
      roomId: dto.roomId,
      content: dto.content,
      userId: client.data.userId as string,
    });

    // Emit to all clients in the room — equivalent to Clients.Group(roomId).SendAsync(...)
    this.server.to(dto.roomId).emit('messageReceived', {
      id: message.id,
      content: message.content,
      userId: message.userId,
      timestamp: message.createdAt.toISOString(),
    });
  }

  // Equivalent to Groups.AddToGroupAsync()
  @SubscribeMessage('joinRoom')
  async handleJoinRoom(
    @MessageBody() data: { roomId: string },
    @ConnectedSocket() client: Socket,
  ): Promise<void> {
    await client.join(data.roomId);    // Socket.io room join — equivalent to SignalR Group

    // Notify others in the room
    client.to(data.roomId).emit('userJoined', {
      userId: client.data.userId,
      roomId: data.roomId,
    });

    // Acknowledge back to the joining client
    client.emit('joinedRoom', { roomId: data.roomId });
  }

  @SubscribeMessage('leaveRoom')
  async handleLeaveRoom(
    @MessageBody() data: { roomId: string },
    @ConnectedSocket() client: Socket,
  ): Promise<void> {
    await client.leave(data.roomId);
    client.to(data.roomId).emit('userLeft', { userId: client.data.userId });
  }
}

DTOs for WebSocket Messages

WebSocket message bodies can be validated with the same class-validator + ValidationPipe approach used for HTTP — but it requires a different pipe setup:

// dto/send-message.dto.ts
import { IsString, IsNotEmpty, MaxLength, IsUUID } from 'class-validator';

export class SendMessageDto {
  @IsUUID()
  roomId: string;

  @IsString()
  @IsNotEmpty()
  @MaxLength(2000)
  content: string;
}
// To validate WebSocket message bodies, apply the pipe at the method level:
import { UsePipes, ValidationPipe } from '@nestjs/common';

@SubscribeMessage('sendMessage')
@UsePipes(new ValidationPipe({ whitelist: true }))
async handleSendMessage(@MessageBody() dto: SendMessageDto, ...) { ... }

Pushing Events from Outside the Gateway (IHubContext Equivalent)

In SignalR you inject IHubContext<THub> into any service to push to connected clients. NestJS has the same pattern — inject the Gateway and use its server property:

// notification.service.ts
import { Injectable } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';

@Injectable()
export class NotificationService {
  constructor(private readonly chatGateway: ChatGateway) {}

  // Equivalent to _hubContext.Clients.Group(roomId).SendAsync(...)
  async notifyRoom(roomId: string, payload: unknown): Promise<void> {
    this.chatGateway.server.to(roomId).emit('systemNotification', payload);
  }

  // Push to a specific connected client by socket ID
  async notifyClient(socketId: string, event: string, payload: unknown): Promise<void> {
    this.chatGateway.server.to(socketId).emit(event, payload);
  }

  // Broadcast to all connected clients — equivalent to Clients.All.SendAsync(...)
  async broadcast(event: string, payload: unknown): Promise<void> {
    this.chatGateway.server.emit(event, payload);
  }
}

Register the Gateway in the module:

// chat.module.ts
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';
import { ChatService } from './chat.service';
import { NotificationService } from './notification.service';

@Module({
  providers: [ChatGateway, ChatService, NotificationService],
  exports: [NotificationService],
})
export class ChatModule {}

Scaling with Redis Adapter

A single NestJS process only knows about sockets connected to it. If you run multiple instances (as you would behind a load balancer), events emitted in one process won’t reach sockets connected to another. This is the same problem SignalR solves with its Redis backplane — and the solution is structurally identical.

// main.ts — add Redis adapter
import { NestFactory } from '@nestjs/core';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Create Redis pub/sub clients
  const pubClient = createClient({ url: process.env.REDIS_URL });
  const subClient = pubClient.duplicate();
  await Promise.all([pubClient.connect(), subClient.connect()]);

  // Apply the Redis adapter — equivalent to AddSignalR().AddStackExchangeRedis(...)
  const redisAdapter = createAdapter(pubClient, subClient);
  const ioAdapter = new IoAdapter(app);
  ioAdapter.createIOServer = (port, options) => {
    const server = super.createIOServer(port, options);
    server.adapter(redisAdapter);
    return server;
  };
  app.useWebSocketAdapter(ioAdapter);

  await app.listen(3000);
}
bootstrap();

In practice, a cleaner approach is to create a custom RedisIoAdapter:

// adapters/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) {
    const server = super.createIOServer(port, options);
    server.adapter(this.adapterConstructor);
    return server;
  }
}

// main.ts
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);

Client-Side Integration (React / Vue)

// hooks/useChatSocket.ts — React hook
import { useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';

interface Message {
  id: string;
  content: string;
  userId: string;
  timestamp: string;
}

interface UseChatSocketOptions {
  roomId: string;
  userId: string;
  onMessage: (msg: Message) => void;
  onUserJoined: (data: { userId: string }) => void;
}

export function useChatSocket({
  roomId,
  userId,
  onMessage,
  onUserJoined,
}: UseChatSocketOptions) {
  const socketRef = useRef<Socket | null>(null);

  useEffect(() => {
    const socket = io('http://localhost:3000/chat', {
      auth: { userId },               // Sent in handshake — available as client.handshake.auth
      transports: ['websocket'],
      reconnectionAttempts: 5,        // Socket.io handles reconnection automatically
      reconnectionDelay: 1000,
    });

    socketRef.current = socket;

    socket.on('connect', () => {
      // Join the room after connecting
      socket.emit('joinRoom', { roomId });
    });

    socket.on('messageReceived', onMessage);
    socket.on('userJoined', onUserJoined);

    socket.on('connect_error', (err) => {
      console.error('Socket connection error:', err.message);
    });

    return () => {
      socket.emit('leaveRoom', { roomId });
      socket.disconnect();
    };
  }, [roomId, userId]);

  const sendMessage = useCallback(
    (content: string) => {
      socketRef.current?.emit('sendMessage', { roomId, content });
    },
    [roomId],
  );

  return { sendMessage };
}

Server-Sent Events as a Simpler Alternative

For one-directional streaming (server to client only), Server-Sent Events are simpler than WebSockets. They work over plain HTTP, need no special adapter, and browsers handle reconnection automatically. Use SSE when you don’t need the client to send messages back over the same channel.

// notifications.controller.ts
import { Controller, Get, Req, Res, Sse, MessageEvent, Param } from '@nestjs/common';
import { Observable, Subject, map, filter } from 'rxjs';
import { Request, Response } from 'express';

@Controller('notifications')
export class NotificationsController {
  // A shared subject — in production, use Redis Pub/Sub instead
  private events$ = new Subject<{ userId: string; payload: unknown }>();

  @Sse('stream/:userId')
  // NestJS @Sse() sets Content-Type: text/event-stream automatically
  stream(@Param('userId') userId: string): Observable<MessageEvent> {
    return this.events$.pipe(
      filter((event) => event.userId === userId),
      map((event) => ({
        data: JSON.stringify(event.payload),
        type: 'notification',
      })),
    );
  }

  // Called from a service to push an event to a specific user
  push(userId: string, payload: unknown) {
    this.events$.next({ userId, payload });
  }
}
// Client-side SSE — native browser API, no library needed
const source = new EventSource('/notifications/stream/user-123');
source.addEventListener('notification', (event) => {
  const payload = JSON.parse(event.data);
  console.log('Received:', payload);
});
source.onerror = () => {
  // Browser retries automatically after an error
  console.log('SSE connection error — browser will retry');
};

Key Differences

ConceptSignalR (.NET)NestJS (Socket.io)
Hub / Gateway classclass ChatHub : Hub@WebSocketGateway() class ChatGateway
Client-callable methodpublic async Task SendMessage(...)@SubscribeMessage('sendMessage')
Push to group / roomClients.Group(id).SendAsync('event', data)server.to(roomId).emit('event', data)
Push to all clientsClients.All.SendAsync('event', data)server.emit('event', data)
Push from serviceIHubContext<THub> injectedInject ChatGateway, use .server
Group membershipGroups.AddToGroupAsync(connId, groupId)client.join(roomId)
Connection lifecycleOnConnectedAsync, OnDisconnectedAsynchandleConnection, handleDisconnect
Connection IDContext.ConnectionIdclient.id
User data on connectionContext.Userclient.handshake.auth, client.data
ScalingRedis backplane via AddStackExchangeRedis()@socket.io/redis-adapter
Transport negotiationAutomatic (WS → SSE → LP)Manual — configure transports option
Client library@microsoft/signalr (npm)socket.io-client (npm)
ProtocolCustom SignalR protocol over WS/HTTPSocket.io protocol over WS/HTTP
NamespaceHub URLnamespace option on @WebSocketGateway()

Gotchas for .NET Engineers

1. Socket.io is not standard WebSocket — the protocols are incompatible

SignalR clients use the SignalR protocol. Socket.io clients use the Socket.io protocol. Neither can connect to the other’s server. This matters when:

  • You have mobile clients using a native WebSocket library (they cannot connect to a Socket.io server without the Socket.io protocol layer)
  • You want to test your gateway with wscat or browser DevTools WebSocket inspector — raw WebSocket tools will show garbage because Socket.io wraps messages in its own framing

If you need standards-compliant WebSocket, use the native ws adapter instead of Socket.io:

npm install @nestjs/platform-ws ws
// main.ts — use the native ws adapter
import { WsAdapter } from '@nestjs/platform-ws';
app.useWebSocketAdapter(new WsAdapter(app));

You lose rooms, reconnection, and automatic transport negotiation — but you gain protocol compatibility with any WebSocket client.

2. SignalR reconnects automatically — Socket.io reconnects but re-joins rooms manually

In SignalR, group membership is preserved across reconnections when using a backplane. In Socket.io, rooms are per-connection: when a client reconnects, it gets a new socket ID and is not in any rooms. The client must re-join rooms after reconnecting.

// Client must handle this explicitly
socket.on('connect', () => {
  // Re-join any rooms the user was in before disconnect
  socket.emit('joinRoom', { roomId: currentRoomId });
});

Design your client to always re-join rooms in the connect event handler, not just on the initial connection.

3. Authentication on WebSocket connections does not work the same as HTTP guards

NestJS HTTP guards use the request object to extract JWT tokens from the Authorization header. WebSocket connections upgrade from HTTP, but once the WebSocket connection is established, headers are not sent on subsequent messages — they are only available during the handshake.

// ws-jwt.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';

@Injectable()
export class WsJwtGuard implements CanActivate {
  constructor(private readonly jwtService: JwtService) {}

  canActivate(context: ExecutionContext): boolean {
    const client: Socket = context.switchToWs().getClient<Socket>();

    // Token must be in the handshake auth object, NOT in a header
    const token = client.handshake.auth.token as string | undefined;

    if (!token) {
      throw new WsException('Missing authentication token');
    }

    try {
      const payload = this.jwtService.verify(token);
      client.data.user = payload; // Attach to socket data for later use
      return true;
    } catch {
      throw new WsException('Invalid token');
    }
  }
}

The client must send the token in auth, not a header:

const socket = io('http://localhost:3000/chat', {
  auth: { token: localStorage.getItem('accessToken') },
});

4. Injecting the Gateway into services creates circular dependency risks

When a service injects ChatGateway and ChatGateway also injects that service (for example, ChatService), you get a circular dependency. NestJS will warn about this at startup. Break the cycle with forwardRef():

// notification.service.ts
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';

@Injectable()
export class NotificationService {
  constructor(
    @Inject(forwardRef(() => ChatGateway))
    private readonly chatGateway: ChatGateway,
  ) {}
}

A cleaner architectural pattern is to have an event bus (using NestJS’s EventEmitter2 or RxJS subjects) that the gateway listens to, rather than injecting the gateway directly into services.

5. The @WebSocketGateway() port option behaves differently from what you expect

By default, @WebSocketGateway() attaches to the same HTTP server port that NestJS uses. If you pass a port argument like @WebSocketGateway(3001), NestJS creates a separate HTTP/WebSocket server on that port — it is not a sub-path of your main server. This can cause CORS issues and complicates deployment. Leave the port unspecified to share the main server port.

// Shares the main HTTP server port — the correct approach for most deployments
@WebSocketGateway({ namespace: '/chat', cors: { origin: process.env.FRONTEND_URL } })

// Creates a SEPARATE server on port 3001 — rarely what you want
@WebSocketGateway(3001)

6. Without the Redis adapter, horizontal scaling silently fails

If you deploy two NestJS instances without the Redis adapter, emitting to a room in instance A will only reach clients connected to instance A. Clients on instance B get nothing. There is no error — the emit simply vanishes. Always add the Redis adapter before deploying behind a load balancer, and verify it is working before scaling beyond one instance.


Hands-On Exercise

Build a real-time collaborative document presence system — users see who else is viewing a document.

Requirements:

  1. Create a PresenceGateway with these events:

    • Client sends joinDocument with { documentId: string } — server adds the client to the document room and broadcasts presenceUpdated to the room with the full list of connected users
    • Client sends leaveDocument — server removes from room, broadcasts updated list
    • handleDisconnect — removes the disconnected user from all rooms they were in, broadcasts updated lists
  2. Create a PresenceService that tracks which users are in which document rooms (use a Map<documentId, Set<userId>>).

  3. Create a DocumentsController with a GET /documents/:id/presence HTTP endpoint that returns the current presence list for a document (not via WebSocket — via regular REST).

  4. Add JWT authentication to the gateway using the handshake auth token approach.

  5. Create a React hook useDocumentPresence(documentId: string) that:

    • Connects and joins the document room on mount
    • Listens for presenceUpdated events and maintains a users: string[] state
    • Leaves the room and disconnects on unmount

Stretch goal: Replace the in-memory Map with Redis so the presence tracking works across multiple server instances. Use ioredis and expire keys after 60 seconds of inactivity.


Quick Reference

TaskSignalRNestJS / Socket.io
Define Hub/Gatewayclass MyHub : Hub@WebSocketGateway() class MyGateway
Client-callable eventpublic Task MyMethod(...)@SubscribeMessage('myEvent')
Get socket/connection IDContext.ConnectionIdclient.id
Store data on connectionContext.Itemsclient.data.key = value
Push to room / groupClients.Group(id).SendAsync(...)server.to(id).emit('event', data)
Push to this client onlyClients.Caller.SendAsync(...)client.emit('event', data)
Push to others in roomClients.OthersInGroup(id).SendAsync(...)client.to(id).emit('event', data)
Push to all connectedClients.All.SendAsync(...)server.emit('event', data)
Join roomGroups.AddToGroupAsync(connId, group)await client.join(roomId)
Leave roomGroups.RemoveFromGroupAsync(...)await client.leave(roomId)
Push from serviceInject IHubContext<THub>Inject gateway, use gateway.server
Connection hookoverride OnConnectedAsync()handleConnection(client: Socket)
Disconnect hookoverride OnDisconnectedAsync()handleDisconnect(client: Socket)
Authenticate[Authorize] + cookie/token in headerWsJwtGuard + handshake.auth.token
Validate message body[Required] on model@UsePipes(ValidationPipe) + DTO
SSE (one-way stream)IActionResult + PushStreamContent@Sse() returning Observable<MessageEvent>
Scale across instancesAddStackExchangeRedis()@socket.io/redis-adapter
Client library@microsoft/signalrsocket.io-client
Connect (client)new HubConnectionBuilder().withUrl(url).build()io('http://localhost:3000/namespace', { auth: {...} })
Reconnection (client)AutomaticAutomatic, but rooms must be re-joined

Further Reading