Real-Time at Scale with Socket.IO
Real-time chat and a live calendar planner are easy on one server and surprising on three. Here is how I designed Socket.IO features in a NestJS ecosystem — rooms, presence, and scaling across instances.
Real-Time at Scale with Socket.IO
On this page
Two of the Amharc Tech features lived or died on real-time: an in-app chat and a dynamic calendar planner where multiple staff moved appointments and everyone saw it instantly. Both ran on Socket.IO inside NestJS. The interesting problems showed up not in the demo, but the moment we ran more than one server instance.
Rooms are the core primitive
You almost never broadcast to everyone. You broadcast to a room — a conversation, a branch’s calendar, a single user’s devices. Socket.IO rooms make the targeting declarative:
@SubscribeMessage('chat:join')
onJoin(@ConnectedSocket() socket: Socket, @MessageBody() conversationId: string) {
socket.join(`conversation:${conversationId}`);
}
// later, emit only to that conversation
this.server.to(`conversation:${conversationId}`).emit('chat:message', message);Authenticate the handshake, not each message
A socket is a long-lived connection, so verify identity once, when it connects, and attach the user to the socket. Every later event already knows who is on the wire:
io.use(async (socket, next) => {
try {
socket.data.user = await verifyAccessToken(socket.handshake.auth.token);
next();
} catch {
next(new Error('unauthorized'));
}
});Presence without a database write per heartbeat
Who is online lives in Redis, not Postgres. On connect, add the user to a set; on disconnect, remove them; let keys expire if a client vanishes. Presence becomes a cheap in-memory read instead of a write storm against the primary database.
The part that breaks at two instances
With one server, every socket shares the same memory, so emit reaches everyone. Add a second instance behind a load balancer and users on server A stop seeing messages from users on server B — they are simply not in the same process. The fix is the Redis adapter: it relays room broadcasts across every instance over pub/sub.
import { createAdapter } from '@socket.io/redis-adapter';
io.adapter(createAdapter(pubClient, subClient));
// now `to(room).emit(...)` reaches matching sockets on ANY instanceYou also need sticky sessions at the load balancer so a client’s long-lived connection keeps landing on the same instance through its lifetime.
The lessons that stuck
- Design around rooms from the start — they are how you scope every broadcast.
- Authenticate the connection once, then trust
socket.data. - Keep ephemeral state (presence, typing) in Redis, not the database.
- The Redis adapter + sticky sessions are not optional the moment you run more than one instance — and you will.
Comments
Loading comments…