Under the Hood

Real-time chat: the socket is delivery, the database is truth

A chat that feels instant is easy to fake and hard to make correct, because the mistake is treating the WebSocket as the system of record. Fable's chat does the opposite: every message is a durable Postgres row written first, then broadcast over Socket.IO as fast delivery — so a dropped socket, a tunnel, or a reconnect never loses a message, it just delays when you see it. This chapter walks that design: persist-then-broadcast, group and user rooms, the Redis pub/sub adapter that lets any server push to any client (the seam that makes horizontal scaling possible), message dedup for flaky-network resends, and why reconnect reconciles against the database, not the socket.

Real-time chat: the socket is delivery, the database is truth

Real-time chat is where the WebSockets lesson stops being theory. The temptation, once you have a bidirectional socket, is to treat it as the whole system: a message is an event you emit to the other people in the room, and you're done. That builds a chat that demos beautifully and loses messages in production — because a socket is a live wire, not a record, and live wires drop. Someone walks into a tunnel, a phone sleeps, a reconnect happens mid-broadcast, and the message you "delivered" is simply gone, with no row anywhere to recover it from.

Fable's chat is built on the opposite principle, and it's the one sentence to take from this chapter: the socket is delivery; the database is truth. Every message is a durable Postgres row first, and the WebSocket is a fast notification layer on top. Get that ordering right and everything else — reconnection, offline catch-up, multi-device, scaling — has a clean answer. Get it backwards and none of them do.

Persist, then broadcast

When you send a message, the chat service does two things in order: it writes the message row to Postgres, and then asks the realtime layer to broadcast it to everyone in the group. Never the reverse, and never one without the other. That ordering is the whole architecture:

  • Because the row is written first, the message exists independently of whether any particular socket received the broadcast. A recipient who was offline during the emit hasn't lost anything — the message is in the database, waiting.
  • Because the broadcast is derived from a committed row, everyone who is connected gets the message instantly, with the same id and ordering the database assigned — so live delivery and later history agree.

This makes the socket best-effort delivery over a source of truth that is durable, which is exactly the right split. Contrast the naive "emit-only" chat: the message lives only in the flight of a socket event, so missing that event means missing the message forever. Fable's messages are ULID-keyed rows in a messages table, read back through the same cursor pagination as any other list — the socket just tells you "there's something new," and the truth is always a query away. (System-generated messages — an expense card, a poll, a shared plan — flow through the same persist-then-broadcast path, so a settlement or expense event becomes a durable, replayable chat row too, not a special case.)

Rooms: how a message finds its recipients

Socket.IO organizes connections into rooms, and Fable uses two kinds:

  • Group rooms (group:<groupId>) — every connected member of a group joins its room, so broadcasting a message is server.to("group:g1").emit(...): fan out to exactly the people in that group, no manual bookkeeping of who's who.
  • User rooms (user:<userId>) — every one of a user's connected devices joins a room named for the user, so a user-targeted event (a notification, a settlement update) reaches all their devices at once, and multi-device "just works."

Membership changes keep the rooms honest: when someone joins or leaves a group, their live sockets are moved into or out of that group's room, so access to the realtime stream tracks access to the group — the same "is this user a member?" tenancy boundary that every REST query respects, enforced on the socket layer too. Rooms also give presence cheaply: "is this user online?" is "does their user room have any sockets?"

The scaling seam: the Redis adapter

Here is the part that connects straight back to the load-balancing lesson's sticky-session story. A WebSocket is stateful — it lives in the memory of one server process. With a single server that's fine. But the moment you run two (for capacity or failover), a problem appears that has no equivalent in stateless REST: user A is connected to server 1, user B to server 2, and A sends a message to their shared group. Server 1 writes the row and broadcasts to its copy of group:g1 — reaching nobody on server 2. B never gets it. The room only exists in the memory of the server that holds those sockets.

The fix is a pub/sub backplane, and Fable wires it with the Socket.IO Redis adapter. Each server opens a Redis publisher and subscriber (a dedicated pair, since a Redis subscriber connection is in subscribe-only mode), and the adapter turns every room broadcast into a Redis publish that all servers subscribe to — so when server 1 emits to group:g1, the event goes through Redis and server 2 delivers it to B's socket. Redis pub/sub becomes the shared nervous system that makes rooms span the whole fleet. This is precisely the WebSockets lesson's "sticky sessions plus a Redis adapter" pattern — sticky sessions pin each connection to a server (because the connection is state that can't move), and the Redis adapter lets any server reach a connection held by any other.

Dedup: "did my message send?" over a flaky network

Chat has the same lost-response problem as money writes: you tap send, the connection drops before the ack, and the client doesn't know if the message landed. Retry naively and you double-post. So every message carries a client-generated client_dedup_key, with a unique constraint (group_id, sender_user_id, client_dedup_key), and a retry with the same key returns the original row instead of inserting a duplicate. It's the exact idempotency-key discipline from the data model and the safe-writes lesson, applied to chat — because "I sent that message twice because my train went through a tunnel" is precisely as real for chat as double-charging is for money, just less catastrophic.

Planned (TDD)

Real-time chat over Socket.IO: emit messages to the group's room so members receive them live.

Shipped

Persist-then-broadcast (every message a durable Postgres row first), group + user rooms with membership-tracked joins, a Redis pub/sub adapter wired for multi-pod fan-out, client_dedup_key message dedup, and reconnect that reconciles against the cursor-paginated message log rather than trusting the socket.

Treating the socket as delivery-not-truth is what keeps messages from vanishing on a dropped connection. And the Redis adapter was added before it was strictly needed because retrofitting a pub/sub backplane into single-process chat is far more invasive than wiring it up front.

Interview takeaway

For any "design chat / a realtime feed / live updates" prompt:

  • "The socket is delivery; the database is the source of truth. I persist the message, then broadcast it." This is the single highest-signal sentence — it shows you know a transport is not a record.
  • "On reconnect, the client reconciles against the persisted log via cursor pagination, not against whatever the socket did or didn't deliver." Follows directly, and ties realtime to durable storage.
  • "Connections are grouped into rooms; a group room fans a message to members, a user room reaches all of one user's devices."
  • "A WebSocket is stateful and lives on one server, so scaling out needs sticky sessions plus a pub/sub backplane (Redis) so any server can reach any connection." Names the exact scaling problem realtime has and REST doesn't.
  • "Messages carry a client dedup key, so a resend over a flaky network doesn't double-post." Idempotency, again.

The series theme holds one last time: making chat feel instant is the easy part — one emit() and it looks alive. Making it correct — never losing a message, agreeing across devices, surviving reconnects, and being ready to scale past one server — is the persist-then-broadcast spine, the rooms, the Redis seam, and the dedup key. The transport-level mechanics of the socket itself are in the WebSockets lesson; the scaling trade-off it forces is in load balancing; where the messages live is the data model.

Go deeper

  • Socket.IO — the Redis adapter Exactly how the pub/sub backplane makes rooms span multiple servers, including why it needs a separate subscriber connection — the mechanism behind Fable's scaling seam.
  • Socket.IO — Rooms The room abstraction Fable's group:/user: model is built on: joining, leaving, and broadcasting to a room — the fan-out primitive of the whole chat.
  • Ably — realtime delivery guarantees and reconnection A clear treatment of why real-time transports are best-effort and why durable storage plus reconnection-reconciliation is the correct pattern — the war story, generalized.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Explain the principle "the socket is delivery, the database is truth," and contrast Fable's persist-then-broadcast with an emit-only chat. What exactly does an emit-only chat lose when a socket drops?
  2. Why must the message row be written before the broadcast, not after? What property does that ordering give a recipient who was offline during the emit?
  3. Describe group rooms and user rooms and what each enables (fan-out to members; all of one user's devices). How do room memberships stay aligned with group access?
  4. A WebSocket lives on one server. Walk through why two users on two servers can't see each other's messages without help, and how the Redis pub/sub adapter fixes it.
  5. Fable runs one server today but wired the Redis adapter anyway. Give the specific justification for paying that complexity before it's needed, in terms of how invasive the change is later.
  6. A successful emit() is not a delivered message. Explain the failure that realization prevents, and how reconnect-time reconciliation against the cursor-paginated log makes a lost emit cost only latency, not the message.