Skip to content
NestJS projects 5 min read

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 from client.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

OptionWherePurpose
namespace@WebSocketGatewayIsolate a feature onto its own URL path
cors.origin@WebSocketGatewayAllow browser clients from given origins
transportsgateway optionsRestrict to ['websocket'] to skip polling
handshake.auth.tokenclient connectPass the JWT used in handleConnection
pingTimeoutserver optionsTune dead-connection detection

Best practices

  • Verify the JWT once in handleConnection and stash the user on client.data rather 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 a ValidationPipe and DTOs; never trust client input on a socket.
  • Track presence in Redis (a shared online set), not in process memory, so counts stay correct across nodes.
Last updated June 14, 2026
Was this helpful?