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
- Redis is essential for multi-server: Don't try to build real-time without a pub/sub layer
- Authentication is critical: Always verify tokens on connection
- Throttle aggressively: Clients can't handle 100 updates/second
- Rooms are your friend: Group clients by what they're watching
- Handle disconnections gracefully: Networks are unreliable
- 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.

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.