DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Scaling WebSockets with Claude Code: Redis Pub/Sub and Socket.io Adapter (2026-03-11)

Multiple WebSocket servers mean users on different servers can't receive each other's messages. Redis Pub/Sub solves this. Claude Code generates the correct architecture automatically from your CLAUDE.md.

CLAUDE.md for WebSocket Scaling

# WebSocket Rules
- Use @socket.io/redis-adapter; messages via Redis Pub/Sub
- Sessions stored in Redis (no sticky sessions required)
- Monitor connection count per user
- Max 5 connections per user
- 30-minute timeout for inactive connections
- JWT auth on handshake; invalid token = immediate disconnect
Enter fullscreen mode Exit fullscreen mode

createSocketServer()

import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

export async function createSocketServer(httpServer: any) {
  const io = new Server(httpServer, {
    cors: { origin: process.env.ALLOWED_ORIGINS?.split(','), credentials: true },
    transports: ['websocket', 'polling'],
  });

  const pubClient = createClient({ url: process.env.REDIS_URL });
  const subClient = pubClient.duplicate();
  await Promise.all([pubClient.connect(), subClient.connect()]);

  io.adapter(createAdapter(pubClient, subClient));

  applyAuthMiddleware(io);
  applyConnectionLimitMiddleware(io, pubClient);
  registerConnectionHandler(io, pubClient);

  return io;
}
Enter fullscreen mode Exit fullscreen mode

JWT Auth Middleware

function applyAuthMiddleware(io: Server) {
  io.use((socket, next) => {
    const token = socket.handshake.auth.token;
    if (!token) return next(new Error('Authentication required'));

    try {
      const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
      socket.data.userId = payload.userId;
      socket.data.tenantId = payload.tenantId;
      next();
    } catch {
      next(new Error('Invalid token'));
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Connection Limit Middleware (Redis Set)

function applyConnectionLimitMiddleware(io: Server, redis: RedisClientType) {
  io.use(async (socket, next) => {
    const connectionKey = `connections:${socket.data.userId}`;
    const count = await redis.sCard(connectionKey);

    if (count >= 5) {
      return next(new Error('Connection limit exceeded (max 5)'));
    }

    await redis.sAdd(connectionKey, socket.id);
    await redis.expire(connectionKey, 1800); // 30min TTL

    socket.on('disconnect', async () => {
      await redis.sRem(connectionKey, socket.id);
    });

    next();
  });
}
Enter fullscreen mode Exit fullscreen mode

Connection Handler

function registerConnectionHandler(io: Server, redis: RedisClientType) {
  io.on('connection', (socket) => {
    const { userId, tenantId } = socket.data;

    // Auto-join tenant and user rooms
    socket.join(`tenant:${tenantId}`);
    socket.join(`user:${userId}`);

    // Room join with access check
    socket.on('join:room', async (roomId: string) => {
      const hasAccess = await checkRoomAccess(userId, roomId);
      if (!hasAccess) {
        socket.emit('error', { message: 'Access denied' });
        return;
      }
      socket.join(roomId);
    });

    // Message event: save to DB + broadcast via adapter (all servers)
    socket.on('message', async (data: MessagePayload) => {
      const message = await saveMessageToDB(data, userId);
      io.to(data.roomId).emit('message:new', message);
    });

    // Inactive timeout: 30 minutes
    const inactiveTimer = setTimeout(() => {
      socket.disconnect(true);
    }, 30 * 60 * 1000);

    socket.on('disconnect', () => clearTimeout(inactiveTimer));
  });
}
Enter fullscreen mode Exit fullscreen mode

Broadcasting Helpers

// Broadcast to all users in a tenant (all servers via adapter)
export function broadcastToTenant(io: Server, tenantId: string, event: string, data: any) {
  io.to(`tenant:${tenantId}`).emit(event, data);
}

// Notify a specific user across all their connections
export function notifyUser(io: Server, userId: string, event: string, data: any) {
  io.to(`user:${userId}`).emit(event, data);
}
Enter fullscreen mode Exit fullscreen mode

Why This Works

  • Redis Adapter: io.to(roomId).emit() reaches users on any server — the adapter routes messages through Redis Pub/Sub automatically
  • No sticky sessions: Any server can handle any client; session state lives in Redis
  • Connection tracking: Redis Set per user; enforces the 5-connection limit across all server instances
  • JWT on handshake: Auth runs once at connect time, before any events are processed

Summary

CLAUDE.md defines the constraints → Claude Code generates createSocketServer() with the Redis adapter wired up, JWT middleware, connection limit enforcement via Redis Set, and room-based broadcasting that works across every server instance.


Want Claude Code to generate safe, production-ready code from your specs?

Code Review Pack (¥980) — prompt collection for architecture review, security check, and refactoring → prompt-works.jp

Top comments (0)