WebSocket Real-Time Updates Pattern
Template for implementing real-time features using WebSocket in this project.
Overview
This pattern was established with the Character Sync Progress feature and serves as a template for future real-time updates (guild roster sync, event notifications, etc.).
Architecture
flowchart TD
subgraph Frontend
hook["React Hook<br/>(useCharacterSync)"]
ws["WebSocket<br/>(useWebSocket)"]
end
subgraph Backend["NestJS Backend"]
gw["Gateway<br/>(CharacterSyncGateway)<br/>Room-based subscriptions"]
proc["Background Processor<br/>(CharacterEnrichmentProcessor)<br/>Bull queue worker"]
end
ws <-->|Socket.io| gw
proc -->|Emits events| gw
Backend Implementation
1. Create WebSocket Gateway
Location: apps/api/src/domains/[domain]/gateways/[feature].gateway.ts
@WebSocketGateway({
cors: {
origin: process.env.WEB_URL || 'http://localhost:5173',
credentials: true,
},
namespace: '/[feature-name]', // e.g., /character-sync
})
export class FeatureGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
constructor(private readonly prisma: PrismaService) {}
handleConnection(client: Socket) {
console.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket) {
console.log(`Client disconnected: ${client.id}`);
}
@SubscribeMessage('subscribe-[feature]')
async handleSubscribe(
@MessageBody() data: { sessionId: string },
@ConnectedSocket() client: Socket
) {
// Join room for this session
client.join(data.sessionId);
// Send current state immediately
const state = await this.prisma.[model].findUnique({ where: { id: data.sessionId } });
this.emitProgress(data.sessionId, state);
}
@SubscribeMessage('unsubscribe-[feature]')
handleUnsubscribe(
@MessageBody() data: { sessionId: string },
@ConnectedSocket() client: Socket
) {
client.leave(data.sessionId);
}
// Method called by background processor
emitProgress(sessionId: string, progress: any) {
this.server.to(sessionId).emit('[feature]-progress', {
sessionId,
...progress,
});
}
emitComplete(sessionId: string, result: any) {
this.server.to(sessionId).emit('[feature]-complete', {
sessionId,
...result,
});
}
emitError(sessionId: string, error: string) {
this.server.to(sessionId).emit('[feature]-error', {
sessionId,
error,
});
}
}
2. Update Background Processor
Inject the gateway and emit events as work progresses:
@Processor('[queue-name]')
export class FeatureProcessor {
constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => FeatureGateway))
private readonly gateway: FeatureGateway
) {}
@Process()
async handleJob(job: Job<JobData>): Promise<void> {
// Do work...
// Update session/state
const updated = await this.prisma.[model].update({
where: { id: sessionId },
data: { processedCount: current + 1 },
});
// Emit real-time update
this.gateway.emitProgress(sessionId, updated);
// On completion
if (isComplete) {
this.gateway.emitComplete(sessionId, finalData);
}
}
}
3. Register in Module
@Module({
imports: [
BullModule.registerQueue({ name: '[queue-name]' }),
forwardRef(() => OtherModule), // If needed for circular deps
],
providers: [
FeatureGateway,
FeatureProcessor,
// ...
],
exports: [FeatureGateway],
})
export class FeatureModule {}
Frontend Implementation
1. Use Generic WebSocket Hook
Already created: apps/web/src/hooks/useWebSocket.ts
const { isConnected, emit, on } = useWebSocket('/feature-name', {
autoConnect: false, // Connect manually when needed
onConnect: () => console.log('Connected'),
onDisconnect: () => console.log('Disconnected'),
onError: (err) => console.error('Error:', err),
});
2. Create Feature-Specific Hook
Location: apps/web/src/hooks/use[Feature]Progress.ts
import { useEffect, useState, useCallback } from 'react';
import { useWebSocket } from './useWebSocket';
import { useAppDispatch } from '../store/hooks';
import { featureApi } from '../store/api/featureApi';
export interface FeatureProgress {
sessionId: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
// ...other fields
}
export function useFeatureProgress() {
const dispatch = useAppDispatch();
const [sessionId, setSessionId] = useState<string | null>(null);
const [progress, setProgress] = useState<FeatureProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const { isConnected, emit, on } = useWebSocket('/feature-name', {
autoConnect: false,
onConnect: () => {
if (sessionId) {
emit('subscribe-[feature]', { sessionId });
}
},
});
useEffect(() => {
if (!isConnected || !sessionId) return;
emit('subscribe-[feature]', { sessionId });
const unsubProgress = on('[feature]-progress', (data: FeatureProgress) => {
setProgress(data);
});
const unsubComplete = on('[feature]-complete', (data: FeatureProgress) => {
setProgress(data);
// Refetch data via RTK Query
dispatch(
featureApi.util.invalidateTags([{ type: 'Feature', id: 'LIST' }])
);
});
const unsubError = on(
'[feature]-error',
(data: { sessionId: string; error: string }) => {
setError(data.error);
}
);
return () => {
emit('unsubscribe-[feature]', { sessionId });
unsubProgress();
unsubComplete();
unsubError();
};
}, [isConnected, sessionId, emit, on, dispatch]);
const startFeature = useCallback(
async (params: any) => {
setError(null);
setProgress(null);
const response = await dispatch(
featureApi.endpoints.startFeature.initiate(params)
).unwrap();
setSessionId(response.sessionId);
return response;
},
[dispatch]
);
return {
progress,
error,
isConnected,
isProcessing: progress?.status === 'processing',
isComplete: progress?.status === 'completed',
startFeature,
};
}
3. Create Progress Component
Location: apps/web/src/components/[Feature]Progress.tsx
import { Card, CardBody, Progress, Chip } from '@heroui/react';
import type { FeatureProgress } from '../hooks/use[Feature]Progress';
interface FeatureProgressProps {
progress: FeatureProgress;
}
export function FeatureProgress({ progress }: FeatureProgressProps) {
const statusColor = {
pending: 'default',
processing: 'primary',
completed: 'success',
failed: 'danger',
}[progress.status];
return (
<Card>
<CardBody className="gap-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Feature Progress</h3>
<Chip color={statusColor} size="sm" variant="flat">
{progress.status}
</Chip>
</div>
<Progress
value={progress.processed}
maxValue={progress.total}
color={statusColor}
showValueLabel={true}
/>
{progress.status === 'completed' && (
<div className="text-success">✓ Complete!</div>
)}
</CardBody>
</Card>
);
}
4. Use in Page Component
import { useFeatureProgress } from '../../hooks/use[Feature]Progress';
import { FeatureProgress } from '../../components/[Feature]Progress';
function FeaturePage() {
const { progress, startFeature } = useFeatureProgress();
const handleStart = async () => {
await startFeature({ /* params */ });
};
return (
<div>
{progress && <FeatureProgress progress={progress} />}
<button onClick={handleStart}>Start Feature</button>
</div>
);
}
Key Benefits
- No Polling - Server pushes updates instantly
- Room-Based - Multiple users can track different sessions simultaneously
- Reconnection Handling - Socket.io handles reconnects automatically
- Current State on Subscribe - New connections get immediate state
- Auto-Refetch - RTK Query invalidates cache on completion
- Reusable - Generic
useWebSockethook for all features - Type-Safe - Full TypeScript support
Testing
Backend (Manual Test)
# Install wscat
npm install -g wscat
# Connect to gateway
wscat -c ws://localhost:3001/[feature-name]
# Subscribe
{"event": "subscribe-[feature]", "data": {"sessionId": "uuid-here"}}
# Listen for events
# Will receive: [feature]-progress, [feature]-complete, [feature]-error
Frontend
// In component
const { progress, startFeature } = useFeatureProgress();
useEffect(() => {
console.log('Progress update:', progress);
}, [progress]);
Implemented: Instance Sync Progress
Journal instance sync (Blizzard raids/dungeons) uses this pattern:
- Backend Gateway:
apps/api/src/game-data/gateways/instance-sync.gateway.ts - Backend Processor:
apps/api/src/jobs/journal-instance-sync.processor.ts - Frontend Hook:
apps/web/src/hooks/useInstanceSync.ts - Frontend Component:
apps/web/src/components/InstanceSyncProgress.tsx - Usage: Admin panel (
/admin) — trigger sync, subscribe to progress via WebSocket
Events: instance-sync-progress, instance-sync-complete, instance-sync-error
Future Features to Add
Using this pattern, we can add:
- Guild Roster Sync Progress - Track roster sync with avatar fetching
- Event Notifications - Real-time event updates for guild members
- Loot Distribution - Live updates during loot distribution
- Audit Log Streaming - Real-time audit log entries
- Performance Monitoring - Live server/queue health metrics
Environment Variables
Backend (apps/api/.env.local):
# No additional vars needed, Socket.io uses same port as NestJS
Frontend (apps/web/.env.local):
VITE_API_URL=http://localhost:3001
File Structure
apps/
├── api/
│ └── src/
│ ├── common/
│ │ └── external-api/ # External API adapters
│ ├── domains/
│ │ └── [domain]/
│ │ ├── gateways/
│ │ │ ├── [feature].gateway.ts
│ │ │ └── README.md
│ │ └── ...
│ └── jobs/
│ └── [feature]-processor.ts
└── web/
└── src/
├── hooks/
│ ├── useWebSocket.ts # Generic WebSocket hook
│ └── use[Feature].ts # Feature-specific hook
└── components/
└── [Feature]Progress.tsx # Progress UI component
Complete Examples
Character Sync (original):
- Backend Gateway:
apps/api/src/domains/character/gateways/character-sync.gateway.ts - Backend Processor:
apps/api/src/jobs/character-enrichment.processor.ts - Frontend Hook:
apps/web/src/hooks/useCharacterSync.ts - Frontend Component:
apps/web/src/components/CharacterSyncProgress.tsx - Frontend Usage:
apps/web/src/routes/characters/index.tsx
Instance Sync (journal raids/dungeons):
- Backend Gateway:
apps/api/src/game-data/gateways/instance-sync.gateway.ts - Backend Processor:
apps/api/src/jobs/journal-instance-sync.processor.ts - Frontend Hook:
apps/web/src/hooks/useInstanceSync.ts - Frontend Component:
apps/web/src/components/InstanceSyncProgress.tsx - Frontend Usage:
apps/web/src/routes/admin.tsx
This serves as the canonical reference for implementing future real-time features.