← Terug naar blog
AI Engineering
7 oktober 2025

WebSocket schalen met de Redis Socket.IO adapter

Hoe je real-time verbindingen over meerdere server-instanties distribueert — met rooms, aanwezigheidsdetectie en herstel bij reconnect
Aron Heesakkers
Aron HeesakkersFullstack Developer & AI Engineer

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.


Project bespreken?

Heb je een use case waar je over twijfelt?

We praten graag over of een AI-aanpak past — en zeggen het eerlijk als het beter zonder kan. Vaste prijs, afgesproken scope.
Plan een gesprek
Probeer AI