Real-time Communication: SignalR vs. WebSockets in NestJS
For .NET engineers who know: SignalR Hubs, groups,
Clients.All,Clients.Group(), and theIHubContext<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
| Concept | SignalR (.NET) | NestJS (Socket.io) |
|---|---|---|
| Hub / Gateway class | class ChatHub : Hub | @WebSocketGateway() class ChatGateway |
| Client-callable method | public async Task SendMessage(...) | @SubscribeMessage('sendMessage') |
| Push to group / room | Clients.Group(id).SendAsync('event', data) | server.to(roomId).emit('event', data) |
| Push to all clients | Clients.All.SendAsync('event', data) | server.emit('event', data) |
| Push from service | IHubContext<THub> injected | Inject ChatGateway, use .server |
| Group membership | Groups.AddToGroupAsync(connId, groupId) | client.join(roomId) |
| Connection lifecycle | OnConnectedAsync, OnDisconnectedAsync | handleConnection, handleDisconnect |
| Connection ID | Context.ConnectionId | client.id |
| User data on connection | Context.User | client.handshake.auth, client.data |
| Scaling | Redis backplane via AddStackExchangeRedis() | @socket.io/redis-adapter |
| Transport negotiation | Automatic (WS → SSE → LP) | Manual — configure transports option |
| Client library | @microsoft/signalr (npm) | socket.io-client (npm) |
| Protocol | Custom SignalR protocol over WS/HTTP | Socket.io protocol over WS/HTTP |
| Namespace | Hub URL | namespace 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
wscator 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:
-
Create a
PresenceGatewaywith these events:- Client sends
joinDocumentwith{ documentId: string }— server adds the client to the document room and broadcastspresenceUpdatedto 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
- Client sends
-
Create a
PresenceServicethat tracks which users are in which document rooms (use aMap<documentId, Set<userId>>). -
Create a
DocumentsControllerwith a GET/documents/:id/presenceHTTP endpoint that returns the current presence list for a document (not via WebSocket — via regular REST). -
Add JWT authentication to the gateway using the handshake auth token approach.
-
Create a React hook
useDocumentPresence(documentId: string)that:- Connects and joins the document room on mount
- Listens for
presenceUpdatedevents and maintains ausers: 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
| Task | SignalR | NestJS / Socket.io |
|---|---|---|
| Define Hub/Gateway | class MyHub : Hub | @WebSocketGateway() class MyGateway |
| Client-callable event | public Task MyMethod(...) | @SubscribeMessage('myEvent') |
| Get socket/connection ID | Context.ConnectionId | client.id |
| Store data on connection | Context.Items | client.data.key = value |
| Push to room / group | Clients.Group(id).SendAsync(...) | server.to(id).emit('event', data) |
| Push to this client only | Clients.Caller.SendAsync(...) | client.emit('event', data) |
| Push to others in room | Clients.OthersInGroup(id).SendAsync(...) | client.to(id).emit('event', data) |
| Push to all connected | Clients.All.SendAsync(...) | server.emit('event', data) |
| Join room | Groups.AddToGroupAsync(connId, group) | await client.join(roomId) |
| Leave room | Groups.RemoveFromGroupAsync(...) | await client.leave(roomId) |
| Push from service | Inject IHubContext<THub> | Inject gateway, use gateway.server |
| Connection hook | override OnConnectedAsync() | handleConnection(client: Socket) |
| Disconnect hook | override OnDisconnectedAsync() | handleDisconnect(client: Socket) |
| Authenticate | [Authorize] + cookie/token in header | WsJwtGuard + handshake.auth.token |
| Validate message body | [Required] on model | @UsePipes(ValidationPipe) + DTO |
| SSE (one-way stream) | IActionResult + PushStreamContent | @Sse() returning Observable<MessageEvent> |
| Scale across instances | AddStackExchangeRedis() | @socket.io/redis-adapter |
| Client library | @microsoft/signalr | socket.io-client |
| Connect (client) | new HubConnectionBuilder().withUrl(url).build() | io('http://localhost:3000/namespace', { auth: {...} }) |
| Reconnection (client) | Automatic | Automatic, but rooms must be re-joined |
Further Reading
- NestJS WebSockets — the official gateway reference with decorators, lifecycle hooks, and adapter configuration
- Socket.io Documentation — the Socket.io server and client reference, including rooms, namespaces, and the Redis adapter
- @socket.io/redis-adapter — scaling Socket.io across multiple processes with Redis
- NestJS Server-Sent Events — the
@Sse()decorator andObservableintegration - SignalR vs. Socket.io — Protocol Comparison — Microsoft’s SignalR docs explain the protocol differences that matter when mixing stacks
- WsAdapter — native WebSocket without Socket.io — when you need standards-compliant WebSocket without the Socket.io protocol layer