Separate Chat History From Live Delivery
Chat history and live message delivery are two systems, not one. Treat the durable log and the real-time channel separately or you will lose messages at scale.
The mistake that quietly limits a lot of chat and messaging apps is treating the WebSocket as the source of truth. Messages flow through the socket, get shown on screen, and that is the whole system. It works until a client is offline, or reconnects, or a server restarts, and then messages are just gone, because the socket only ever carried what was happening live. Chat is actually two systems wearing one interface: a durable, ordered log that is the real record, and a live channel that pushes new entries to whoever is connected right now. Build them as two things, connected but distinct, and the hard problems, offline users, reconnects, history, catch-up, all get straightforward. Conflate them and every one of those problems becomes a bug.
The socket is a delivery mechanism, not a database
A WebSocket is a pipe. It carries messages to clients that are connected at the instant a message is sent. That is all it does. It has no memory. If a client is not connected when a message goes out, the socket does not hold that message for them; it is simply not delivered. Treating the socket as your system of record is treating a pipe as a warehouse. The pipe moves things; it does not store them.
So the record has to live somewhere durable. Every message, when accepted, gets written to an ordered log in a real database before or as it is broadcast. That log is the truth. The socket broadcast is a convenience layered on top, a way to tell currently-connected clients "here is a new entry" without them having to poll. Once you internalize that the log is the app and the socket is an optimization, the architecture clicks into place.
Two systems, two jobs
The durable log's job is to remember everything, in order, forever, or for as long as your retention needs. It answers questions like "what are the last fifty messages in this conversation" and "everything after message 40," which is what powers history views and catch-up after a reconnect. It is a storage and query problem, and it wants the properties of a database: durability, ordering, indexed reads.
The live channel's job is to push new entries to connected clients fast, with low latency, fanned out to everyone watching. It answers the question "something just happened, tell the people who are here." It is a delivery problem, and it wants the properties of a real-time system: fast fan-out, presence, backpressure handling. These are genuinely different jobs with different requirements, which is exactly why trying to make one component do both leads to compromise on both. The monotonic sequence numbers are the seam that ties them together: the log assigns them, the live channel carries them, and clients use them to reconcile the two.
How the two systems work together
The write path is: message arrives, server assigns it the next sequence number and writes it to the durable log, then broadcasts it on the live channel to connected clients. Connected clients get it instantly from the channel. That is the fast path, and it covers the common case where everyone is online.
The read and recover paths lean on the log. A client opening a conversation loads recent history from the log, not from the socket. A client reconnecting after a drop asks the log for everything after its cursor, fills the gap, then resumes the live channel for anything new. An offline user's messages are all sitting safely in the log waiting for them. In every case the socket handles "now" and the log handles "everything," and the client stitches them with the sequence number so there is no gap and no duplicate. This division is the concrete version of the point I made in the backend real-time apps actually need: real-time delivery and durable state are separate responsibilities.
Why conflating them fails at exactly the wrong time
If you skip the log and rely on the socket, everything looks fine in testing, where clients stay connected and servers do not restart. Then production arrives with real networks and real deploys. A user on a subway loses messages. A server restart drops in-flight state. A reconnect resumes live and silently misses the gap. These are not rare; they are the normal operating conditions of a real app, and they are precisely the moments a socket-only design fails. The failures cluster at the worst times because the missing piece, durable memory, is only needed when something goes wrong, which is exactly when you find out you do not have it.
I build these as two layers deliberately. The durable ordered log lives in a real database, and the live delivery runs on the real-time layer of AltoHost, which handles the fan-out, presence, and reconnection so I am not tempted to smuggle storage responsibilities into the socket. Keeping the boundary clean is what makes offline, history, and catch-up boring instead of terrifying. If you are still deciding whether a database plus a socket is your architecture versus a bundled real-time database, I compared those in real-time database vs a custom backend.
The test is to kill things on purpose: disconnect a client, restart a server, send messages during the outage. If anything is lost, your socket was doubling as your database and it failed at the job it was never built for. If everything is intact and the client catches up cleanly, you kept the two systems separate. The log remembers; the socket delivers. Do not ask either one to be the other.