← Back to Blog
Building Real-Time Features with WebSockets and Redis

When we added real-time collaboration to the Catalyst PSA platform—live updates to project timesheets, instant notifications, real-time presence indicators—we discovered that building truly scalable real-time features is harder than it looks.

After handling millions of WebSocket messages and scaling to support hundreds of concurrent users collaborating on shared projects, here's everything we learned about building real-time features the right way.

Why Real-Time Matters in Enterprise Software

Traditional request-response works fine for many features, but enterprise collaboration demands real-time:

  • Time tracking: When a team member logs time, project managers see it immediately
  • Notifications: Critical alerts need instant delivery
  • Presence: Who's currently viewing this project?
  • Collaborative editing: Multiple users editing schedules simultaneously
  • Live dashboards: Metrics updating as data changes

Polling (checking the server every N seconds) is wasteful and doesn't scale. WebSockets provide persistent connections for bidirectional communication.

The Architecture: WebSockets + Redis Pub/Sub

Our real-time architecture has three layers:

1. WebSocket Gateway (Connection Layer)

NestJS WebSocket gateway manages client connections:

// websocket/realtime.gateway.ts
@WebSocketGateway({
  cors: {
    origin: process.env.CORS_ORIGINS?.split(',') || [],
    credentials: true,
  },
})
export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(
    private readonly authService: AuthService,
    private readonly redisService: RedisService
  ) {}
 
  async handleConnection(client: Socket) {
    try {
      // Authenticate connection
      const token = client.handshake.auth.token;
      const user = await this.authService.verifyToken(token);
 
      // Store user context on socket
      client.data.userId = user.id;
      client.data.tenantId = user.tenantId;
 
      // Join user-specific room for private messages
      client.join(`user:${user.id}`);
 
      // Publish connection event
      await this.redisService.publish('user:connected', {
        userId: user.id,
        tenantId: user.tenantId,
        timestamp: new Date(),
      });
 
      console.log(`Client connected: ${user.id}`);
    } catch (error) {
      console.error('Connection auth failed:', error);
      client.disconnect();
    }
  }
 
  async handleDisconnect(client: Socket) {
    const userId = client.data.userId;
 
    if (userId) {
      await this.redisService.publish('user:disconnected', {
        userId,
        timestamp: new Date(),
      });
 
      console.log(`Client disconnected: ${userId}`);
    }
  }
}

2. Redis Pub/Sub (Message Bus)

Redis handles message distribution across multiple server instances:

// infrastructure/redis/redis-pubsub.service.ts
@Injectable()
export class RedisPubSubService {
  private publisher: Redis;
  private subscriber: Redis;
  private handlers: Map<string, Set<MessageHandler>> = new Map();
 
  constructor() {
    this.publisher = new Redis(process.env.REDIS_URL);
    this.subscriber = new Redis(process.env.REDIS_URL);
 
    // Listen for messages
    this.subscriber.on('message', (channel, message) => {
      this.handleMessage(channel, JSON.parse(message));
    });
  }
 
  async publish(channel: string, data: any): Promise<void> {
    await this.publisher.publish(channel, JSON.stringify(data));
  }
 
  async subscribe(channel: string, handler: MessageHandler): Promise<void> {
    if (!this.handlers.has(channel)) {
      this.handlers.set(channel, new Set());
      await this.subscriber.subscribe(channel);
    }
 
    this.handlers.get(channel)!.add(handler);
  }
 
  private handleMessage(channel: string, data: any): void {
    const handlers = this.handlers.get(channel);
    if (handlers) {
      handlers.forEach((handler) => handler(data));
    }
  }
}

3. Event Publishers (Business Logic)

Domain events trigger real-time updates:

// application/events/time-entry-created.handler.ts
@EventsHandler(TimeEntryCreatedEvent)
export class TimeEntryCreatedRealtimeHandler {
  constructor(private readonly redisPubSub: RedisPubSubService) {}
 
  async handle(event: TimeEntryCreatedEvent): Promise<void> {
    // Publish to Redis so all server instances can broadcast
    await this.redisPubSub.publish('time-entry:created', {
      tenantId: event.tenantId,
      projectId: event.projectId,
      timeEntry: event.timeEntry,
    });
  }
}

Implementing Specific Real-Time Features

Feature 1: Live Time Entry Updates

When someone logs time, everyone viewing that project sees it instantly:

// Server side - subscribe to Redis and emit to WebSocket clients
@WebSocketGateway()
export class RealtimeGateway implements OnGatewayInit {
  @WebSocketServer()
  server: Server;
 
  async afterInit() {
    // Subscribe to time entry events from Redis
    await this.redisPubSub.subscribe('time-entry:created', (data) => {
      // Emit to all clients viewing this project
      this.server
        .to(`project:${data.projectId}`)
        .emit('time-entry:created', data.timeEntry);
    });
  }
 
  @SubscribeMessage('watch-project')
  async handleWatchProject(client: Socket, projectId: string) {
    // Verify user has access to project
    const hasAccess = await this.checkProjectAccess(
      client.data.userId,
      projectId
    );
 
    if (!hasAccess) {
      throw new WsException('Unauthorized');
    }
 
    // Join project room to receive updates
    client.join(`project:${projectId}`);
 
    return { success: true };
  }
 
  @SubscribeMessage('unwatch-project')
  async handleUnwatchProject(client: Socket, projectId: string) {
    client.leave(`project:${projectId}`);
    return { success: true };
  }
}

Client side with React:

// hooks/useProjectRealtimeUpdates.ts
import { io, Socket } from 'socket.io-client';
import { useEffect, useState } from 'react';
 
export function useProjectRealtimeUpdates(projectId: string) {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
 
  useEffect(() => {
    const token = getAuthToken();
 
    const socketInstance = io(process.env.NEXT_PUBLIC_WS_URL, {
      auth: { token },
    });
 
    socketInstance.on('connect', () => {
      console.log('WebSocket connected');
 
      // Start watching project
      socketInstance.emit('watch-project', projectId);
    });
 
    socketInstance.on('time-entry:created', (timeEntry: TimeEntry) => {
      setTimeEntries((prev) => [...prev, timeEntry]);
 
      // Show toast notification
      toast.success(`New time entry: ${timeEntry.hours} hours logged`);
    });
 
    socketInstance.on('disconnect', () => {
      console.log('WebSocket disconnected');
    });
 
    setSocket(socketInstance);
 
    return () => {
      socketInstance.emit('unwatch-project', projectId);
      socketInstance.disconnect();
    };
  }, [projectId]);
 
  return { socket, timeEntries };
}

Usage in component:

const ProjectTimeEntries = ({ projectId }) => {
  const { timeEntries } = useProjectRealtimeUpdates(projectId);
 
  return (
    <div>
      {timeEntries.map((entry) => (
        <TimeEntryCard key={entry.id} entry={entry} />
      ))}
    </div>
  );
};

Feature 2: Real-Time Notifications

Push notifications to specific users:

@EventsHandler(TaskAssignedEvent)
export class TaskAssignedNotificationHandler {
  constructor(private readonly redisPubSub: RedisPubSubService) {}
 
  async handle(event: TaskAssignedEvent): Promise<void> {
    // Send notification to assigned user
    await this.redisPubSub.publish('notification', {
      userId: event.assigneeId,
      type: 'task-assigned',
      title: 'New Task Assigned',
      message: `You've been assigned to: ${event.taskName}`,
      data: {
        taskId: event.taskId,
        projectId: event.projectId,
      },
      timestamp: new Date(),
    });
  }
}
 
// Gateway listens and emits to specific user
@WebSocketGateway()
export class RealtimeGateway {
  async afterInit() {
    await this.redisPubSub.subscribe('notification', (data) => {
      // Send to specific user's room
      this.server.to(`user:${data.userId}`).emit('notification', data);
    });
  }
}

Client notification handler:

export function useNotifications() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  useEffect(() => {
    const socket = io(process.env.NEXT_PUBLIC_WS_URL, {
      auth: { token: getAuthToken() },
    });
 
    socket.on('notification', (notification: Notification) => {
      setNotifications((prev) => [notification, ...prev]);
 
      // Show desktop notification if permitted
      if (Notification.permission === 'granted') {
        new Notification(notification.title, {
          body: notification.message,
          icon: '/icon.png',
        });
      }
 
      // Play sound
      new Audio('/notification.mp3').play();
    });
 
    return () => socket.disconnect();
  }, []);
 
  return { notifications };
}

Feature 3: User Presence

Show who's currently viewing a project:

@WebSocketGateway()
export class RealtimeGateway {
  private presenceMap: Map<string, Set<string>> = new Map(); // projectId -> Set<userId>
 
  @SubscribeMessage('watch-project')
  async handleWatchProject(client: Socket, projectId: string) {
    const userId = client.data.userId;
 
    // Add to presence map
    if (!this.presenceMap.has(projectId)) {
      this.presenceMap.set(projectId, new Set());
    }
    this.presenceMap.get(projectId)!.add(userId);
 
    // Join room
    client.join(`project:${projectId}`);
 
    // Notify others of new viewer
    this.server.to(`project:${projectId}`).emit('user-joined', {
      userId,
      projectId,
    });
 
    // Send current viewers to newly joined user
    const viewers = Array.from(this.presenceMap.get(projectId)!);
    client.emit('current-viewers', { projectId, viewers });
 
    return { success: true };
  }
 
  @SubscribeMessage('unwatch-project')
  async handleUnwatchProject(client: Socket, projectId: string) {
    const userId = client.data.userId;
 
    // Remove from presence map
    this.presenceMap.get(projectId)?.delete(userId);
 
    client.leave(`project:${projectId}`);
 
    // Notify others
    this.server.to(`project:${projectId}`).emit('user-left', {
      userId,
      projectId,
    });
 
    return { success: true };
  }
 
  async handleDisconnect(client: Socket) {
    const userId = client.data.userId;
 
    // Remove from all projects
    for (const [projectId, viewers] of this.presenceMap.entries()) {
      if (viewers.has(userId)) {
        viewers.delete(userId);
        this.server.to(`project:${projectId}`).emit('user-left', {
          userId,
          projectId,
        });
      }
    }
  }
}

Display presence in UI:

const ProjectPresence = ({ projectId }) => {
  const [viewers, setViewers] = useState<string[]>([]);
  const socket = useWebSocket();
 
  useEffect(() => {
    socket.on('current-viewers', ({ viewers }) => {
      setViewers(viewers);
    });
 
    socket.on('user-joined', ({ userId }) => {
      setViewers((prev) => [...prev, userId]);
    });
 
    socket.on('user-left', ({ userId }) => {
      setViewers((prev) => prev.filter((id) => id !== userId));
    });
  }, [socket]);
 
  return (
    <div className="flex items-center space-x-2">
      <Users className="w-4 h-4" />
      <span>{viewers.length} viewing</span>
      <div className="flex -space-x-2">
        {viewers.slice(0, 3).map((userId) => (
          <UserAvatar key={userId} userId={userId} />
        ))}
        {viewers.length > 3 && (
          <div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200">
            +{viewers.length - 3}
          </div>
        )}
      </div>
    </div>
  );
};

Scaling WebSockets with Redis

The critical insight: WebSocket connections are stateful and tied to specific server instances. Without Redis, messages only go to clients connected to the same server.

The Problem: Multiple Server Instances

User A -> Server 1
User B -> Server 2

User A creates time entry
Server 1 gets event
Server 1 emits to its clients (only User A)
User B never gets update!

The Solution: Redis Pub/Sub

User A -> Server 1
User B -> Server 2

User A creates time entry
Server 1 publishes to Redis
All servers (1 and 2) receive from Redis
Both servers emit to their clients
Users A and B both get update!

Implementation:

@WebSocketGateway()
export class RealtimeGateway implements OnGatewayInit {
  @WebSocketServer()
  server: Server;
 
  constructor(private readonly redisPubSub: RedisPubSubService) {}
 
  async afterInit() {
    // Every server instance subscribes to Redis
    await this.redisPubSub.subscribe('*', (channel, data) => {
      // Emit received messages to WebSocket clients
      this.server.emit(channel, data);
    });
  }
}

This works across any number of server instances, automatically load-balanced.

Performance Optimizations

1. Message Throttling

Prevent overwhelming clients with too many updates:

export class ThrottledEmitter {
  private queues: Map<string, any[]> = new Map();
  private timers: Map<string, NodeJS.Timeout> = new Map();
 
  emit(room: string, event: string, data: any, throttleMs = 1000) {
    const key = `${room}:${event}`;
 
    if (!this.queues.has(key)) {
      this.queues.set(key, []);
    }
 
    this.queues.get(key)!.push(data);
 
    if (!this.timers.has(key)) {
      this.timers.set(
        key,
        setTimeout(() => {
          this.flush(room, event, key);
        }, throttleMs)
      );
    }
  }
 
  private flush(room: string, event: string, key: string) {
    const batch = this.queues.get(key) || [];
 
    if (batch.length > 0) {
      this.server.to(room).emit(event, batch);
    }
 
    this.queues.delete(key);
    this.timers.delete(key);
  }
}

Usage:

// Instead of emitting every individual update
this.server.to(`project:${projectId}`).emit('time-entry:created', entry);
 
// Batch them
this.throttledEmitter.emit(
  `project:${projectId}`,
  'time-entry:created',
  entry,
  1000 // Emit at most once per second
);

2. Selective Subscription

Don't subscribe to everything. Let clients choose what they want:

@SubscribeMessage('subscribe')
async handleSubscribe(
  client: Socket,
  payload: { channels: string[] }
): Promise<void> {
  for (const channel of payload.channels) {
    // Validate access
    const hasAccess = await this.checkAccess(client.data.userId, channel);
 
    if (hasAccess) {
      client.join(channel);
    }
  }
}

Client:

socket.emit('subscribe', {
  channels: [
    `project:${projectId}`,
    `user:${userId}`,
    'global-notifications',
  ],
});

3. Connection Pooling

Reuse WebSocket connections across components:

// lib/websocket.ts
let socket: Socket | null = null;
 
export function getWebSocket(): Socket {
  if (!socket) {
    socket = io(process.env.NEXT_PUBLIC_WS_URL, {
      auth: { token: getAuthToken() },
    });
  }
 
  return socket;
}
 
// All components share one connection
const socket = getWebSocket();

Monitoring and Debugging

Connection Monitoring

Track connection health:

@WebSocketGateway()
export class RealtimeGateway {
  private metrics = {
    totalConnections: 0,
    activeConnections: 0,
    messagesPerSecond: 0,
  };
 
  handleConnection(client: Socket) {
    this.metrics.totalConnections++;
    this.metrics.activeConnections++;
 
    // Expose metrics endpoint
    this.metricsService.gauge('websocket.active_connections', this.metrics.activeConnections);
  }
 
  handleDisconnect(client: Socket) {
    this.metrics.activeConnections--;
    this.metricsService.gauge('websocket.active_connections', this.metrics.activeConnections);
  }
}

Message Logging

Log messages for debugging (disable in production):

if (process.env.NODE_ENV === 'development') {
  socket.onAny((event, ...args) => {
    console.log(`[WebSocket] ${event}`, args);
  });
}

Lessons Learned

  1. Redis is essential for multi-server: Don't try to build real-time without a pub/sub layer
  2. Authentication is critical: Always verify tokens on connection
  3. Throttle aggressively: Clients can't handle 100 updates/second
  4. Rooms are your friend: Group clients by what they're watching
  5. Handle disconnections gracefully: Networks are unreliable
  6. Monitor everything: Track connections, message rates, errors

Conclusion

Building real-time features with WebSockets and Redis transformed Catalyst PSA into a collaborative platform. Team members see updates instantly, get notified immediately, and can see who else is working on projects in real-time.

The architecture—WebSocket gateways + Redis pub/sub + domain events—scales horizontally and has handled millions of messages reliably.

After 27 years of building applications, I can say that real-time features are no longer optional for modern enterprise software. Users expect instant updates, and with WebSockets and Redis, delivering that experience is achievable at scale.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.