WebSocket schalen met de Redis Socket.IO adapter

Een Socket.IO-server op één instantie is eenvoudig: alle verbindingen zitten in hetzelfde proces, een server.to(room).emit() bereikt iedereen in die room. Zodra je twee instanties draait — achter een load balancer, of in Kubernetes — breekt dat model.
Instantie A heeft socket abc123 van gebruiker 42. Instantie B heeft socket def456 van diezelfde gebruiker op een andere tab. Als een event voor gebruiker 42 binnenkomt op instantie A, bereikt het abc123 — maar def456 op instantie B hoort niets.
De Redis adapter lost dit op: alle instanties delen één pub/sub-kanaal via Redis. Een emit op instantie A wordt via Redis doorgegeven aan alle andere instanties, die het event doorsturen naar hun eigen lokale sockets.
De adapter bouwen
We extenden IoAdapter van NestJS om de Redis-verbinding te wrapppen:
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter>;
private pubClient: RedisClient;
private subClient: RedisClient;
async connectToRedis(): Promise<void> {
const redisUrl = this.configService.get<string>("REDIS_URL");
const isTls = redisUrl.startsWith("rediss://");
const options: RedisClientOptions = {
url: redisUrl,
socket: {
tls: isTls,
reconnectStrategy: (retries) => {
if (retries > 50) return new Error("max retries reached");
return Math.min(100 * Math.pow(2, retries - 1), 5000);
},
},
};
this.pubClient = createClient(options);
this.subClient = this.pubClient.duplicate();
await Promise.all([this.pubClient.connect(), this.subClient.connect()]);
this.adapterConstructor = createAdapter(this.pubClient, this.subClient);
}
createIOServer(port: number, options?: ServerOptions): unknown {
const server = super.createIOServer(port, options) as Server;
if (this.adapterConstructor) {
server.adapter(this.adapterConstructor);
}
return server;
}
}
De reconnectStrategy implementeert exponential backoff: elke mislukte reconnect-poging wacht twee keer zo lang als de vorige, met een cap op 5 seconden. Na 50 pogingen geeft het op. Dat is de juiste trade-off: agressief genoeg om snel te herstellen na een korte Redis-outage, conservatief genoeg om geen storm van reconnect-pogingen te veroorzaken.
De pubClient en subClient zijn twee aparte Redis-verbindingen. Dat is een vereiste van de Socket.IO Redis adapter: één client voor publish, één voor subscribe, omdat een Redis-client in subscribe-modus geen andere commands kan uitvoeren.
TLS wordt automatisch ingeschakeld op basis van het URL-schema (rediss:// vs redis://). In productie gebruikt Render of Railway een TLS-URL; lokaal draait Redis zonder TLS.
Rooms en gebruikersgroepen
Bij verbinding worden clients automatisch ingedeeld in rooms op basis van hun identiteit:
@WebSocketGateway({ namespace: '/admin' })
export class WebsocketGateway {
async handleConnection(client: Socket) {
const user = await this.authService.validateToken(
this.extractToken(client)
)
// Persoonlijke room voor direct-to-user events
client.join(`user:${user.id}`)
// Organisatie-room voor broadcast aan alle medewerkers van één org
client.join(`org:${user.organizationId}`)
// Join alle actieve conversatie-rooms van deze gebruiker
const conversations = await this.getActiveConversations(user.id)
for (const conv of conversations) {
client.join(`conversation:${conv.id}`)
}
}
}
De namespace /admin isoleert dit WebSocket-kanaal van andere namespaces. Dat maakt het mogelijk om later een aparte namespace voor eindgebruikers te openen met een andere auth-strategie, zonder interferentie.
Token-extractie probeert drie plekken in volgorde: handshake.auth.token (meest modern, via client-side socket.auth), de Authorization-header, en als fallback een sessiecookie. Dat maakt de gateway bruikbaar vanuit zowel browser-clients als API-clients.
Aanwezigheidsdetectie
Één gebruiker kan meerdere sockets hebben: twee browsertabs, een mobiele app. We bijhouden een Map<userId, Set<socketId>>:
private userSockets = new Map<string, Set<string>>()
async handleConnection(client: Socket) {
// ... auth, room joins ...
const existing = this.userSockets.get(user.id) ?? new Set()
existing.add(client.id)
this.userSockets.set(user.id, existing)
await this.emitPresence(user.id, 'online')
await this.sendPendingNotifications(client, user)
}
async handleDisconnect(client: Socket) {
const userId = this.socketUserMap.get(client.id)
if (!userId) return
const sockets = this.userSockets.get(userId)
sockets?.delete(client.id)
// Pas offline broadcasten als de laatste socket vertrekt
if (!sockets?.size) {
this.userSockets.delete(userId)
await this.emitPresence(userId, 'offline')
}
}
Aanwezigheid wordt pas als "offline" gemarkeerd als de laatste socket van een gebruiker verbreekt. Dat voorkomt false-negative aanwezigheidsevents als een gebruiker op een tweede tab inlogt en de eerste tab sluit.
Pending notifications bij reconnect
Supabase Realtime heeft connection state recovery ingebouwd. Wij implementeren iets vergelijkbaars voor Socket.IO: bij verbinding sturen we gemiste notificaties:
async sendPendingNotifications(client: Socket, user: User): Promise<void> {
const unread = await this.notificationService.getUnread(user.id, {
limit: 20,
since: user.lastSeenAt,
})
if (unread.length > 0) {
client.emit('notification:batch', { notifications: unread })
}
}
De connectionStateRecovery op de server-config geeft Socket.IO 2 minuten om te proberen de sessie te hervatten bij een korte disconnect:
const server = super.createIOServer(port, {
...options,
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
},
});
Binnen die 2 minuten probeert de client automatisch te reconnecten op dezelfde sessie, inclusief alle rooms. Bij een succesvol herstel sturen we toch de batch-notificaties — de client weet niet welke events hij heeft gemist tijdens de disconnect.
Wanneer Redis, wanneer niet?
De Redis adapter voegt latentie toe aan elk emit: het event gaat van de emitterende instantie naar Redis, en van Redis naar alle andere instanties. Voor hoog-frequente events (typingIndicators, cursor-posities) kan dat merkbaar zijn.
De praktische drempelwaarde: als je applicatie op één instantie draait met voldoende geheugen, win je niets met Redis. Voeg de adapter toe op het moment dat je daadwerkelijk horizontaal schaalt — niet preventief. De createIOServer-methode is zo geschreven dat als adapterConstructor niet gezet is, de standaard in-memory adapter wordt gebruikt. Lokaal ontwikkelen gaat dus altijd zonder Redis.