WebSocket Real-Time Updates Pattern
Template for implementing real-time features in this project using RTK Query streaming subscriptions over Socket.IO.
Overview
The frontend WebSocket layer used to be a custom useWebSocket React hook that each domain hook wrapped (one socket per consumer, manual reconnect, useState everywhere). That pattern has been retired.
The current pattern moves the socket lifecycle out of React and lets RTK Query own subscriber refcounting:
- One persistent socket per namespace, owned by a module-scoped factory in
@toast-guilder/web-shared/lib/socket. - Each real-time stream is an RTK Query endpoint with an empty
queryFnand anonCacheEntryAddedbody that opens/closes the subscription. - Components consume the generated
useGet*Queryhook — they never touch the socket directly. - On logout,
closeAllSockets()is called from the auth layer so the next sign-in opens fresh sockets with the new session cookie.
This is the canonical pattern documented in the RTK Query docs as streaming updates.
Architecture
flowchart TD
subgraph Frontend
comp["Component"]
hook["Domain hook<br/>(useRosterSync, useSimSync, ...)"]
rtkq["RTK Query endpoint<br/>(syncProgressApi)<br/>onCacheEntryAdded"]
factory["socketFactory<br/>getSocket(namespace)"]
end
subgraph Backend["NestJS Backend"]
gw["Gateway<br/>Room-based subscriptions"]
proc["Bull Processor<br/>(emits progress)"]
end
comp --> hook
hook --> rtkq
rtkq -->|getSocket| factory
factory <-->|Socket.IO| gw
proc -->|emits| gw
Refcounting and dedup happens at two levels:
- Socket level —
getSocket(namespace)returns the sameSocketinstance for the lifetime of the page. Multiple endpoints sharing a namespace share the connection. - Subscription level — RTK Query refcounts at the cache-key level. Two components watching the same
sessionIdshare one subscription; the first subscriber opens it, the last unsubscribes.
Backend Implementation
Unchanged — the backend gateway pattern is still what it was. Summarized here for completeness; see existing gateways (character-sync.gateway.ts, roster-sync.gateway.ts, meta-build-sync.gateway.ts) for live examples.
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]',
})
export class FeatureGateway
implements OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
@SubscribeMessage('subscribe-sync')
async handleSubscribe(
@MessageBody() data: { sessionId: string },
@ConnectedSocket() client: Socket
) {
client.join(data.sessionId);
// Optionally send current state immediately so late joiners catch up
const state = await this.prisma.featureSession.findUnique({
where: { id: data.sessionId },
});
if (state) this.emitProgress(data.sessionId, state);
}
@SubscribeMessage('unsubscribe-sync')
handleUnsubscribe(
@MessageBody() data: { sessionId: string },
@ConnectedSocket() client: Socket
) {
client.leave(data.sessionId);
}
emitProgress(sessionId: string, progress: FeatureProgress) {
this.server.to(sessionId).emit('sync-progress', progress);
}
emitComplete(sessionId: string, result: FeatureProgress) {
this.server.to(sessionId).emit('sync-complete', result);
}
emitError(sessionId: string, error: string) {
this.server.to(sessionId).emit('sync-error', { sessionId, error });
}
}
2. Background Processor Emits Events
Inject the gateway into your Bull processor and emit sync-progress / sync-complete / sync-error to the room as work moves along. (No change from the previous pattern.)
3. Authentication
Gateways accept the same session cookie as the HTTP API (credentials: true + withCredentials: true on the client). No bespoke auth on the WS layer.
Frontend Implementation
1. Use the Shared getSocket() Factory — Don't Open Your Own
Lives in packages/web-shared/src/lib/socket/socketFactory.ts and re-exported as @toast-guilder/web-shared/lib.
import { getSocket, closeAllSockets } from '@toast-guilder/web-shared/lib';
const socket = getSocket('/feature-name');
- One
Socketinstance per namespace, created lazily on first request. withCredentials: trueso the session cookie rides along — auth comes from the same cookie as REST.closeAllSockets()is called from the authlogout()flow so a session change forces a clean reconnect.
Don't call io() yourself or hold a Socket in component state. That breaks the single-socket-per-namespace invariant.
2. Add a Streaming Endpoint to syncProgressApi
All sync streams live in one place: apps/web/src/store/api/syncProgressApi.ts (which re-exports @toast-guilder/web-shared/api/syncProgressApi). Add a new endpoint there rather than spinning up a new file per feature.
// packages/web-shared/src/api/syncProgressApi.ts
export interface FeatureProgress {
sessionId: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
processed: number;
total: number;
}
export interface FeatureState {
progress: FeatureProgress | null;
error: string | null;
}
export const syncProgressApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getFeatureSyncProgress: builder.query<FeatureState, string>({
// queryFn never fetches HTTP — it just seeds the initial cache shape
queryFn: () => ({ data: { progress: null, error: null } }),
async onCacheEntryAdded(
sessionId,
{ updateCachedData, cacheDataLoaded, cacheEntryRemoved, dispatch }
) {
const socket = getSocket('/feature-name');
try {
await cacheDataLoaded;
} catch {
return; // cache entry removed before initial data resolved
}
socket.emit('subscribe-sync', { sessionId });
const onProgress = (data: FeatureProgress) => {
updateCachedData(() => ({ progress: data, error: null }));
};
const onComplete = (data: FeatureProgress) => {
updateCachedData(() => ({ progress: data, error: null }));
// Invalidate any HTTP queries that depend on this stream
dispatch(
baseApi.util.invalidateTags([{ type: 'Feature', id: 'LIST' }])
);
};
const onError = (data: { sessionId: string; error: string }) => {
updateCachedData((draft) => {
draft.error = data.error;
});
};
socket.on('sync-progress', onProgress);
socket.on('sync-complete', onComplete);
socket.on('sync-error', onError);
await cacheEntryRemoved;
socket.emit('unsubscribe-sync', { sessionId });
socket.off('sync-progress', onProgress);
socket.off('sync-complete', onComplete);
socket.off('sync-error', onError);
},
}),
}),
});
export const { useGetFeatureSyncProgressQuery } = syncProgressApi;
Key points:
queryFnreturns the initial shape only. RTK Query is the cache, the socket is the producer.onCacheEntryAddedis the entire lifecycle: subscribe on first interest, unsubscribe when the last consumer goes away (pluskeepUnusedDataFor).- Use
updateCachedData((draft) => {...})(Immer) for partial mutations; use() => newStatefor full replacements. - Dispatch tag invalidation here when the stream completes, so dependent HTTP queries refetch.
3. Watching Multiple IDs at Once
For admin/batch flows that subscribe to several sessions in one shot, use a list cache key with a custom serializeQueryArgs. The cached shape is a map keyed by id:
getRosterSyncProgress: builder.query<RosterSyncMap, string[]>({
queryFn: () => ({ data: {} as RosterSyncMap }),
serializeQueryArgs: ({ queryArgs }) => [...queryArgs].sort().join(','),
async onCacheEntryAdded(sessionIds, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
if (!sessionIds.length) return;
const socket = getSocket('/roster-sync');
await cacheDataLoaded;
sessionIds.forEach((id) => socket.emit('subscribe-sync', { sessionId: id }));
const onProgress = (data: RosterSyncProgress) => {
updateCachedData((draft) => {
draft[data.sessionId] = data;
});
};
socket.on('sync-progress', onProgress);
await cacheEntryRemoved;
sessionIds.forEach((id) => socket.emit('unsubscribe-sync', { sessionId: id }));
socket.off('sync-progress', onProgress);
},
}),
This is the pattern used by getRosterSyncProgress (admin batch) and getBatchClassification. Single-session consumers pass [sessionId] and pluck their entry out.
4. Thin Domain Hook
Domain hooks are now small wrappers around the generated query hook. No useState for progress, no useEffect for subscribe/unsubscribe, no manual socket plumbing.
// apps/web/src/hooks/sync/useFeatureSync.ts
import { useState } from 'react';
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useGetFeatureSyncProgressQuery } from '@/store/api/syncProgressApi';
export function useFeatureSync() {
const [sessionId, setSessionId] = useState<string | null>(null);
const { data } = useGetFeatureSyncProgressQuery(sessionId ?? skipToken);
return {
progress: data?.progress ?? null,
error: data?.error ?? null,
isSyncing: sessionId !== null && data?.progress?.status !== 'completed',
startSync: (id: string) => setSessionId(id),
};
}
skipToken is critical — it tells RTK Query not to mount the subscription until you actually have a session id, so the socket isn't opened on every page render.
5. Component Usage
import { useFeatureSync } from '@/hooks/sync/useFeatureSync';
function FeaturePage() {
const { progress, isSyncing, startSync } = useFeatureSync();
const [trigger] = useStartFeatureMutation();
const handleStart = async () => {
const { sessionId } = await trigger().unwrap();
startSync(sessionId);
};
return (
<>
<Button onPress={handleStart} isDisabled={isSyncing}>Start</Button>
{progress && <FeatureSyncProgress progress={progress} />}
</>
);
}
Why This Pattern
- No socket-per-consumer leakage — components can't accidentally open duplicate sockets.
- No
useState/useEffectplumbing in domain hooks — RTK Query owns the cache, the factory owns the connection. - Automatic dedup — two components watching the same session share one subscription.
- StrictMode/Activity safe —
keepUnusedDataForkeeps the cache entry alive across remounts, so the socket doesn't churn. - Cross-app reuse — both
apps/webandapps/adminconsume the same@toast-guilder/web-shared/api/syncProgressApi. - Clean logout —
closeAllSockets()is called fromauth/auth.tson sign-out, ensuring the next user gets fresh connections.
Testing
Streaming endpoints
The pattern test files are in apps/web/src/store/api/syncProgressApi.realtime.test.tsx and notificationsApi.realtime.test.tsx. They mock socket.io-client with an event-emitting stub and assert that cached state updates as events fire.
Use those as the template — don't roll your own socket mock per test.
Manual gateway smoke test
npm install -g wscat
wscat -c ws://localhost:3001/feature-name
{"event": "subscribe-sync", "data": {"sessionId": "uuid-here"}}
Migrating an Existing Hook
If you find a hook that still does io(...) directly, holds a socket in useRef, or imports a now-deleted useWebSocket:
- Move its socket subscription into a new endpoint on
syncProgressApi(onCacheEntryAdded). - Replace the hook body with
useGet*Query(arg ?? skipToken). - Delete the manual
useEffectsubscribe/unsubscribe block. - Confirm
getSocket()is the only placeio(...)is called.
File Structure
packages/web-shared/src/
├── api/
│ └── syncProgressApi.ts # All streaming RTK Query endpoints
└── lib/
└── socket/
└── socketFactory.ts # getSocket() + closeAllSockets()
apps/web/src/
├── hooks/sync/
│ └── use[Feature]Sync.ts # Thin wrapper around useGet*Query
├── lib/
│ ├── socket/socketFactory.ts # Re-exports from web-shared
│ └── auth/auth.ts # Calls closeAllSockets() on logout
└── store/api/
├── baseApi.ts # RTK Query base with tagTypes
└── syncProgressApi.ts # Re-exports from web-shared
Reference Implementations
- Roster sync (admin batch, multi-session):
getRosterSyncProgressendpoint +useRosterSync/useAdminRosterSynchooks - Character sync (per-user Battle.net):
getCharacterSyncProgressendpoint +useCharacterSynchook - GDL sync (game-data catalogs):
getGdlSyncProgressendpoint - Meta-build sync:
getMetaBuildSyncProgressendpoint - Notifications (non-sync streaming example):
notificationsApi.onCacheEntryAddedfor/notificationsnamespace
All of these consume getSocket() from the same factory; there is one socket per namespace shared across every consumer of every cache key inside that namespace.