Project: Real-Time Chat App
Real-time chat is the canonical use case for WebSockets: a persistent, bidirectional channel where the server can push messages to clients the instant they arrive. NestJS makes this ergonomic through gateways, which wrap Socket.IO (or native ws) in the same decorator-driven, dependency-injected model you already use for HTTP controllers. In this project you’ll build an authenticated chat server with rooms, presence tracking, message persistence, and a Redis adapter that lets you scale across multiple Node processes without losing a single event.
Project setup
Install the platform and Redis dependencies. We use Socket.IO because it ships reconnection, rooms, and the Redis adapter out of the box.
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
npm install @socket.io/redis-adapter redis
npm install @nestjs/jwt
The feature lives in a single ChatModule so it stays self-contained and easy to test.
// src/chat/chat.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ChatGateway } from './chat.gateway';
import { ChatService } from './chat.service';
import { MessageRepository } from './message.repository';
@Module({
imports: [JwtModule.register({ secret: process.env.JWT_SECRET })],
providers: [ChatGateway, ChatService, MessageRepository],
})
export class ChatModule {}
The WebSocket gateway
A gateway is declared with @WebSocketGateway. The @WebSocketServer() decorator injects the underlying Socket.IO Server, and lifecycle hooks (OnGatewayConnection, OnGatewayDisconnect) let you react to clients joining and leaving. Message handlers are marked with @SubscribeMessage, mirroring how @Post works for controllers.
// src/chat/chat.gateway.ts
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { UnauthorizedException, UseGuards } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { ChatService } from './chat.service';
interface AuthedSocket extends Socket {
data: { userId: string; username: string };
}
@WebSocketGateway({ cors: { origin: '*' }, namespace: '/chat' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
constructor(
private readonly chat: ChatService,
private readonly jwt: JwtService,
) {}
async handleConnection(client: AuthedSocket) {
try {
const token = client.handshake.auth?.token as string;
const payload = await this.jwt.verifyAsync(token);
client.data = { userId: payload.sub, username: payload.username };
await this.chat.markOnline(payload.sub);
} catch {
client.emit('error', 'Unauthorized');
client.disconnect();
}
}
async handleDisconnect(client: AuthedSocket) {
if (!client.data?.userId) return;
await this.chat.markOffline(client.data.userId);
this.server.emit('presence', await this.chat.onlineUsers());
}
}
Authenticate in
handleConnection, not in a per-message guard. A socket lives for the whole session, so validating the JWT once at the handshake (read fromclient.handshake.auth.token) avoids re-verifying on every event.
Rooms and presence
Socket.IO rooms are server-side groupings of sockets. Joining a room is a single call, and server.to(room).emit(...) broadcasts to everyone in it. We store presence in Redis so it stays accurate across every node in the cluster.
// src/chat/chat.gateway.ts (handlers)
@SubscribeMessage('join')
async onJoin(
@ConnectedSocket() client: AuthedSocket,
@MessageBody() room: string,
) {
await client.join(room);
const history = await this.chat.recentMessages(room);
client.emit('history', history);
this.server.to(room).emit('system', `${client.data.username} joined`);
}
@SubscribeMessage('message')
async onMessage(
@ConnectedSocket() client: AuthedSocket,
@MessageBody() dto: { room: string; text: string },
) {
const saved = await this.chat.persist({
room: dto.room,
text: dto.text,
userId: client.data.userId,
username: client.data.username,
});
this.server.to(dto.room).emit('message', saved);
return { status: 'ok', id: saved.id };
}
Returning a value from a handler sends an acknowledgement back to the calling client only — useful for delivery confirmation without an extra broadcast.
Persisting messages
The service layer keeps WebSocket concerns out of your data logic, exactly as services do for HTTP. Here it persists messages and tracks online users in Redis.
// src/chat/chat.service.ts
import { Injectable } from '@nestjs/common';
import { MessageRepository } from './message.repository';
import { createClient } from 'redis';
@Injectable()
export class ChatService {
private redis = createClient({ url: process.env.REDIS_URL });
constructor(private readonly messages: MessageRepository) {
this.redis.connect();
}
persist(input: { room: string; text: string; userId: string; username: string }) {
return this.messages.create({ ...input, createdAt: new Date() });
}
recentMessages(room: string) {
return this.messages.findRecent(room, 50);
}
async markOnline(userId: string) {
await this.redis.sAdd('online', userId);
}
async markOffline(userId: string) {
await this.redis.sRem('online', userId);
}
onlineUsers() {
return this.redis.sMembers('online');
}
}
Scaling horizontally with the Redis adapter
A single Node process can’t share in-memory room state with its siblings, so a message emitted on node A never reaches a client connected to node B. The Redis adapter fixes this by relaying every emit through a Redis pub/sub channel. Register it before app.listen().
// src/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(url: string): Promise<void> {
const pub = createClient({ url });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
this.adapterConstructor = createAdapter(pub, sub);
}
createIOServer(port: number, options?: ServerOptions) {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}
// src/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 redisAdapter = new RedisIoAdapter(app);
await redisAdapter.connectToRedis(process.env.REDIS_URL);
app.useWebSocketAdapter(redisAdapter);
await app.listen(3000);
}
bootstrap();
With the adapter wired up, run two instances behind a load balancer and confirm cross-node delivery:
Output:
[Nest] 41021 - ChatGateway initialized (namespace /chat)
[node-1] client a8f3 connected user=alice
[node-2] client 91bc connected user=bob
[node-1] message room=general from=alice -> broadcast via redis
[node-2] delivered message id=662f to bob
Connection options reference
| Option | Where | Purpose |
|---|---|---|
namespace | @WebSocketGateway | Isolate a feature onto its own URL path |
cors.origin | @WebSocketGateway | Allow browser clients from given origins |
transports | gateway options | Restrict to ['websocket'] to skip polling |
handshake.auth.token | client connect | Pass the JWT used in handleConnection |
pingTimeout | server options | Tune dead-connection detection |
Best practices
- Verify the JWT once in
handleConnectionand stash the user onclient.datarather than guarding every message. - Keep gateways thin: they translate socket events into service calls, just like controllers do for HTTP.
- Always emit to rooms (
server.to(room)) instead of broadcasting globally, so users only receive events they’re subscribed to. - Persist messages before broadcasting so late joiners can load history and nothing is lost on a crash.
- Use the Redis adapter from day one if you’ll ever run more than one process — retrofitting it after launch is painful.
- Validate
@MessageBody()payloads with aValidationPipeand DTOs; never trust client input on a socket. - Track presence in Redis (a shared
onlineset), not in process memory, so counts stay correct across nodes.