The single-process bottleneck
With 500 concurrent rooms, a single Node.js Socket.IO server maxes out at ~85% CPU. Horizontal scaling is the obvious answer — but with WebSockets, you can't just put a load balancer in front. A client connected to Server A can't receive events emitted by Server B.
Redis Pub/Sub to the rescue
The solution: every server subscribes to a Redis channel per room. When Server A emits to room 'abc', it also publishes to Redis channel 'room:abc'. Server B (and C, D…) receives that message and fans it out to its own connected clients.
// When receiving a yjs:update from a client
socket.on('yjs:update', (payload) => {
// Fan out locally
socket.to(roomId).emit('yjs:update', payload)
// Fan out to other nodes via Redis
redis.publish(`room:${roomId}`, JSON.stringify(payload))
})
// On startup, subscribe to all room channels
redisSub.psubscribe('room:*', (channel, message) => {
const roomId = channel.replace('room:', '')
io.to(roomId).emit('yjs:update', JSON.parse(message))
})Graceful degradation
We always keep an in-memory fallback. If Redis is unreachable on startup, the server logs a warning and continues with local-only fanout. This means a single-node deploy (like a dev environment) just works without Redis installed.