Skip to content

Backend

Realtime Features with Socket.IO That Survive a Second Server

Socket.IO is easy until you scale past one process, refresh the page, or lose the network. Here is the architecture I use — rooms, an adapter, acknowledgements and reconnect handling.

3 min read0 views
  • #nodejs
  • #websockets
  • #socketio
  • #realtime
  • #architecture

The demo version of realtime is fifteen lines and works perfectly. The production version has to survive a second server instance, a page refresh mid-update, a phone going through a tunnel, and someone who opened your app in four tabs.

These are the four things that took me from "it works on my machine" to "it works."

1. Rooms, not broadcasts

The first instinct is to broadcast everything and filter on the client. It works with five users and collapses at five hundred — every client receives every message and burns CPU discarding 99% of it. Worse, you're leaking data to browsers that shouldn't see it.

Rooms let the server do the filtering:

server/socket.js
io.on('connection', (socket) => {
  const { userId, orgId } = socket.data; // set during auth, see below
 
  socket.join(`user:${userId}`);   // personal notifications
  socket.join(`org:${orgId}`);     // org-wide events
 
  socket.on('incident:subscribe', ({ incidentId }) => {
    // Authorise before joining — a room name from the client is user input
    if (!canView(socket.data, incidentId)) return;
    socket.join(`incident:${incidentId}`);
  });
 
  socket.on('incident:unsubscribe', ({ incidentId }) => {
    socket.leave(`incident:${incidentId}`);
  });
});
 
// Emitting is now precise
io.to(`incident:${id}`).emit('incident:updated', payload);

Two rules that matter: authorise every join (a room name arriving from the client is untrusted input), and let the client unsubscribe when a component unmounts, so a long session doesn't accumulate a hundred rooms.

2. Authenticate the handshake, not the messages

Checking a token on every message is wasted work. Do it once, when the socket connects, and attach the identity to the socket:

io.use(async (socket, next) => {
  try {
    const token = socket.handshake.auth?.token;
    if (!token) return next(new Error('unauthorized'));
 
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    const user = await User.findById(payload.id).select('_id orgId role').lean();
    if (!user) return next(new Error('unauthorized'));
 
    socket.data = { userId: String(user._id), orgId: String(user.orgId), role: user.role };
    next();
  } catch {
    next(new Error('unauthorized'));
  }
});

Use handshake.auth, not a query string — query strings end up in proxy and access logs.

One caveat worth knowing: a long-lived socket outlives a short-lived access token. If your tokens expire in 15 minutes, either re-verify periodically or have the client reconnect with a fresh token on rotation. A socket authenticated an hour ago is not evidence that the session is still valid.

3. One process is a lie

The moment you run two instances behind a load balancer, this breaks:

io.to(`incident:${id}`).emit('incident:updated', payload);

Instance A only knows about sockets connected to instance A. Half your users see nothing.

The fix is an adapter that puts a pub/sub bus between the instances:

server/io.js
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
 
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
 
export const io = new Server(httpServer, {
  adapter: createAdapter(pubClient, subClient),
  cors: { origin: process.env.APP_URL, credentials: true },
});

Now io.to(room).emit(...) reaches every matching socket on every instance.

The other half of this is sticky sessions. Socket.IO starts on HTTP long-polling and upgrades to WebSocket; if the polling requests land on different instances, the handshake fails with a confusing session ID unknown error. Either enable sticky sessions at the load balancer, or force the transport:

const socket = io(URL, { transports: ['websocket'] });

Forcing WebSocket avoids the problem entirely but drops the fallback for networks that block WS. On a modern stack behind TLS, I take that trade.

4. Assume the network fails

Mobile networks drop constantly. The default client already reconnects — what it can't do is tell you what you missed while you were gone.

The pattern that fixes this: treat the socket as an invalidation signal, not as the source of truth.

hooks/useIncident.ts
useEffect(() => {
  socket.emit('incident:subscribe', { incidentId });
 
  const onUpdate = (patch) => setIncident((prev) => ({ ...prev, ...patch }));
 
  // On every (re)connect, resync from the API — this covers the gap
  const onConnect = () => {
    socket.emit('incident:subscribe', { incidentId });
    void refetchIncident(incidentId);
  };
 
  socket.on('incident:updated', onUpdate);
  socket.on('connect', onConnect);
 
  return () => {
    socket.emit('incident:unsubscribe', { incidentId });
    socket.off('incident:updated', onUpdate);
    socket.off('connect', onConnect);
  };
}, [incidentId]);

The socket delivers fast updates. HTTP delivers correctness. When they disagree, HTTP wins. This one inversion removes an entire category of "the UI is showing something that isn't true" bugs.

Note the cleanup function — without it, a component that remounts registers a second listener and you get duplicate updates. This is the most common Socket.IO bug in React codebases.

Acknowledgements for anything that matters

emit is fire-and-forget. If the client needs to know the server actually processed something, use an acknowledgement with a timeout:

// Client
socket
  .timeout(5000)
  .emit('message:send', draft, (err, ack) => {
    if (err) return markFailed(draft.id);   // no response in 5s
    if (!ack.ok) return markFailed(draft.id);
    markDelivered(draft.id, ack.messageId);
  });
 
// Server
socket.on('message:send', async (draft, callback) => {
  try {
    const message = await Message.create({ ...draft, authorId: socket.data.userId });
    io.to(`room:${draft.roomId}`).emit('message:new', message);
    callback({ ok: true, messageId: String(message._id) });
  } catch (error) {
    callback({ ok: false, error: error.message });
  }
});

This is what makes optimistic UI honest: render immediately, mark as sent on ack, mark as failed on timeout — with a retry the user can actually see.

Things that bit me

Emitting inside a request handler. Tempting, but it couples HTTP and socket lifecycles. Emit from the layer that owns the state change, once, after the write commits.

No payload cap. maxHttpBufferSize defaults to 1 MB. Set it to what you actually need. Nobody should be able to push 900 KB through a chat event.

Unbounded rooms. A "global" room with every connected user is a broadcast with extra steps. Segment by org, project or channel.

No backpressure on high-frequency events. Cursor positions and typing indicators at 60 Hz will melt things. Throttle on the client — 10 Hz is plenty and nobody notices.

The architecture in one paragraph

Authenticate once at the handshake and attach identity to the socket. Put every client in precise, authorised rooms. Run a Redis adapter so any instance can reach any socket. Send acknowledgements for anything the user must know succeeded. And treat every reconnect as a cue to resync from your HTTP API, because the socket is a notification channel — not your database.