Skip to content
Al Shakib E Elahi
02Distributed Systems

Persist, then emit

The one ordering rule that separates real-time features that work from real-time features that work in the demo.

Category
Distributed Systems
Reading
7 min
Published
02 Mar 2026

There is a version of every real-time feature that works perfectly in the demo and falls apart the moment real people use it. It usually comes down to a single line of code being in the wrong order.

// The demo version
io.to(room).emit("message", payload);
await messages.insertOne(payload);

Swap those two lines and most of the hard problems go away.


Why the order matters more than it looks

A socket is a transport. It has no memory, no durability and no guarantees. When you emit first, you have told connected clients that something happened before anything actually happened. Everything that can go wrong between those two lines produces a system where clients and server disagree about reality:

  • The insert fails a validation rule. Connected users saw a message that does not exist and never will.
  • The process dies between the emit and the write. Same outcome, permanently.
  • A user reconnects two seconds later and fetches history. Their message is missing, so it appears to have been deleted.

Emitting second makes the database the single source of truth and the socket a cache invalidation signal. If the write fails, nobody was told. If the write succeeds and the emit fails, the client will pick it up on its next read. Both failure modes converge on the same state.

// The version that survives
const message = await messages.insertOne(payload);
io.to(room).emit("message", message);

The second rule: the client must not trust the socket either

Persisting first fixes the server. It does not fix the client, which still has a gap: everything emitted while it was disconnected.

Mobile networks disconnect constantly. A tunnel, a lift, a carrier handover, the OS suspending your app to save battery. Socket.IO will reconnect transparently, and the reconnected client will happily carry on rendering - with a hole in its history that nothing will ever fill.

The fix is that reconnection is not a transport event, it is a sync event.

socket.on("connect", async () => {
  // Do not resume. Reconcile.
  const since = lastMessageTimestamp ?? conversationOpenedAt;
  const missed = await fetch(`/api/messages?since=${since}`).then((r) => r.json());
  applyMessages(missed);
});

The live socket is an optimisation over polling. It is never the only path by which data reaches the client. Once you internalise that, a dropped connection stops being a correctness problem and becomes a latency problem, which is a much better class of problem to have.


The third rule: emit to a room, not a socket

Storing socket.id against a user is the other bug I see constantly. It holds until a user opens a second tab, or their phone reconnects and receives a new socket ID while the old mapping is still in memory.

Rooms solve this. Join a room keyed on the stable user ID, and let the server track however many connections that user currently has.

io.on("connection", (socket) => {
  const userId = authenticate(socket); // verify the token, do not trust a query param
  socket.join(`user:${userId}`);
});

// Reaches every device and tab this user has open. Zero of your bookkeeping.
io.to(`user:${userId}`).emit("transfer:updated", transfer);

This matters beyond convenience. In the remittance backend I worked on, a user would often start a transfer on their phone and check on it from a browser. Both surfaces need the same state transitions. Room-per-user made that free.


When one server becomes two

Everything above works on a single process. The moment you scale horizontally, io.to(room).emit(...) only reaches sockets connected to this instance. Instance B never hears about it.

The standard fix is a Redis adapter, which fans emits out across instances over pub/sub. It is a two-line change and it is easy to assume it makes the problem disappear.

It does not, quite. It makes delivery work across instances. It does not make delivery guaranteed, because Redis pub/sub is fire-and-forget - a subscriber that is down when a message is published does not receive it later.

Which brings it back to the same place: the socket layer is best-effort, the database is the truth, and the client reconciles on connect. The Redis adapter raises the hit rate of the fast path. It does not remove the need for the slow one.


What this actually buys you

Three rules, and they compose into a system where connectivity is no longer a correctness concern:

  1. Persist, then emit. The write is the event; the emit is a notification about it.
  2. Reconcile on connect. The socket is an optimisation, never the only path.
  3. Address rooms, not sockets. Identity is stable; connections are not.

None of this makes real-time harder to build. It mostly makes it less code - there is no reconnection buffer to maintain, no socket ID registry to clean up, no ordering logic. You just have a database and a way of telling people to look at it sooner.