Chat Application Architecture: Messages, Ordering, Reconnects, Presence, and Multi-Device Sync
A chat application looks simple in the UI: send a message and show it on another screen. Production chat becomes difficult because users reconnect, retry sends, switch devices, edit messages, go offline, join large groups, upload files, and expect unread/read state to remain correct through all of it.
The central design rule is straightforward:
durable message state and realtime delivery are separate concerns.
The database owns message history. WebSockets or another realtime transport make new state arrive quickly.
Start with durable conversations and messages
A minimal relational model might look like:
conversations
id
type
created_at
conversation_members
conversation_id
user_id
joined_at
last_read_sequence
messages
id
conversation_id
sender_id
client_message_id
sequence
body
created_at
edited_at
deleted_at
The exact schema can differ, but three things matter:
- conversation membership is authoritative
- message history is durable
- ordering and deduplication are explicit
A WebSocket server should not be the only place a newly sent message exists.
A send should commit before fan-out
A safe flow is:
client sends message
server authenticates + authorizes
server validates payload
message is committed to durable storage
server acknowledges canonical message ID/sequence
realtime fan-out publishes committed message
push fallback may notify offline devices
Do not broadcast a message to recipients first and write it to the database afterward.
If the database write then fails, clients have seen a message that does not exist in history.
Durability should come before best-effort realtime fan-out.
Give every client send an idempotency identifier
Mobile networks fail at inconvenient moments.
A client may send a message, lose the connection before receiving the acknowledgement, and retry the exact same send.
Without deduplication:
Hello
Hello
Use a client-generated identifier scoped appropriately, for example:
client_message_id = deviceA-01J...
and enforce uniqueness such as:
(sender_id, client_message_id)
or another domain-safe key.
On retry, return the already-created canonical message rather than inserting a duplicate.
Never deduplicate messages merely because their text matches; two identical “OK” messages can both be legitimate.
Define the ordering guarantee explicitly
“Messages are ordered” can mean several things.
Possible guarantees include:
- globally ordered across the entire chat system
- ordered only inside each conversation
- ordered per sender
- approximately ordered by client timestamps
Most chat applications need per-conversation server ordering, not one global sequence for every chat in the product.
A server-assigned monotonic sequence within a conversation gives clients a stable cursor:
conversation c7
sequence 101
sequence 102
sequence 103
The sequence is better for synchronization than trusting client clocks, which can be wrong or deliberately manipulated.
Client time and server time solve different problems
Client time can be useful for UI context while sending.
Canonical ordering should normally come from server-controlled state.
If two clients send concurrently, the server can assign the order when messages become durable.
Do not expect timestamps with millisecond precision to give a reliable distributed total order across devices.
Realtime delivery should carry canonical state
After a message commits, fan out something like:
{
"type": "message.created",
"conversation_id": "c7",
"message": {
"id": "m900",
"sequence": 103,
"sender_id": "u42",
"body": "Hello"
}
}
Clients should reconcile using the canonical message ID and sequence.
If an optimistic local message already exists using client_message_id, replace/confirm it rather than adding a second bubble.
WebSocket is a transport, not a queue
RFC 6455 defines WebSocket as a bidirectional communication channel over a long-lived connection.
It does not make disconnected clients receive old application messages automatically.
If a user disconnects at sequence 103 and reconnects when the conversation has reached 120, the application must recover the gap from durable storage.
For example:
GET /conversations/c7/messages?after_sequence=103
Then resume realtime delivery after the client has caught up.
This recovery path is what makes temporary disconnects safe.
Make reconnect synchronization race-safe
A subtle reconnect bug looks like this:
client starts history catch-up
new realtime message arrives
history response includes or excludes it unpredictably
client duplicates or misses message
Use a defined synchronization protocol.
One pattern is:
- establish authenticated realtime connection
- obtain current high-water sequence for conversation
- fetch missing durable messages up to that sequence
- buffer realtime messages newer than the high-water mark
- apply history
- apply buffered messages in sequence order
Another valid design can use a durable event cursor.
The key is to define the boundary, not hope network timing behaves nicely.
Read receipts scale better as cursors than per-message rows
For a direct conversation, it can be tempting to store:
message m1 read by u2
message m2 read by u2
message m3 read by u2
If reading is monotonic, a cursor is cheaper:
user u2 last_read_sequence = 103
Then every message through 103 is considered read.
For a group chat, each member can maintain a separate cursor.
This reduces write amplification dramatically compared with creating one read-receipt row for every message/member combination.
If the product needs exact per-message read analytics for small groups, store it intentionally—but do not create that cost by accident.
Unread counts should be derived from authoritative cursors
A UI may optimistically increment the unread badge when realtime messages arrive.
But after reconnect or multi-device use, the server must be able to reconcile the count.
Conceptually:
unread = latest_conversation_sequence - member_last_read_sequence
Real implementations may exclude deleted/system messages or use maintained counters for scale.
The important property is that the badge can be reconstructed when a client misses events.
Multi-device delivery needs one user, many sessions
A user may be logged in on:
- browser A
- browser B
- phone
- tablet
Do not model presence or realtime routing as user -> one socket.
Use something like:
user
session/device A -> connection(s)
session/device B -> connection(s)
phone -> connection(s)
A message sent from the phone should normally appear immediately on the user's open browser too.
Likewise, a read cursor advanced on one device should propagate to the others.
Presence is ephemeral state
Presence answers questions such as:
- online
- recently active
- offline
It should usually not be inferred from whether one particular socket exists.
Browsers sleep, mobile OSes suspend apps, networks disappear without clean disconnects, and a user can have several devices.
A practical presence model uses short-lived heartbeats or leases:
session heartbeat
last_seen_at
presence expires if not refreshed
Then aggregate device/session presence into user-level state.
Treat “online” as approximate product state, not a cryptographic truth.
Typing indicators should never be durable messages
Typing is ephemeral.
A useful event can look like:
{
"type": "typing.started",
"conversation_id": "c7"
}
Characteristics:
- short TTL
- lossy delivery is fine
- no historical replay
- rate-limited/debounced
If a typing event is dropped, nothing important was lost.
Do not store every keystroke or typing toggle in the main message database.
Edits need version semantics
Editing a message raises product questions:
- how long can a message be edited?
- should history be retained?
- should clients show “edited”?
- can moderators inspect the original?
A simple current-state model uses:
body
edited_at
A compliance/audit-sensitive system may keep an immutable revision history separately.
Realtime edits should reference the canonical message ID:
message.updated / m900 / version 2
Version numbers help clients reject out-of-order update events.
Deletion is usually a state transition
Hard-deleting a message immediately can break:
- synchronization cursors
- reply references
- moderation workflows
- audit requirements
Many systems mark:
deleted_at
and render a tombstone such as “Message deleted.”
Whether content itself must be erased immediately depends on product/privacy requirements.
Do not confuse an application tombstone with long-term legal retention or deletion obligations.
Replies should reference stable IDs
For threaded replies or quoting:
reply_to_message_id = m700
The UI may copy a short preview for convenience, but the relationship should use stable canonical IDs.
If the original message is deleted, define what the reply displays rather than letting a missing join break the conversation.
Attachments should not live inside WebSocket frames as giant blobs
For large files, a cleaner flow is:
- client asks API for upload authorization
- file uploads directly to object storage or upload service
- malware/content validation runs if required
- application creates attachment metadata
- message references the attachment ID
The chat event transports metadata, not a 200 MB video.
This keeps chat gateways focused on small realtime messages and avoids unnecessary memory/bandwidth pressure.
Treat attachment URLs as authorization-sensitive
Do not assume possession of a predictable storage URL means the user may access the file.
Use:
- authenticated download endpoints
- short-lived signed URLs where appropriate
- conversation membership checks
- content-type/size validation
A user removed from a private conversation should not retain indefinite access simply because an attachment URL leaked into browser history.
Large rooms need different fan-out economics
A direct chat has two participants.
A room with 500,000 members is a different workload.
Naively performing one synchronous delivery operation per member before acknowledging a message can collapse latency.
Possible strategies include:
- asynchronous fan-out
- room/topic subscriptions in realtime infrastructure
- regional fan-out workers
- pull-based history plus lightweight realtime invalidation
- separate handling for extremely large broadcast channels
Do not design the largest broadcast room using the exact same synchronous path as a 1:1 conversation.
Slow clients require bounded buffering
MDN notes that the standard browser WebSocket API does not provide built-in backpressure.
If messages arrive faster than a client can process them, buffers and CPU can grow dangerously.
Server-side gateways should also avoid unbounded per-connection queues.
A safe policy can be:
bounded queue
if client falls too far behind:
disconnect
client reconnects
durable history fills the gap
For durable messages, disconnect-and-recover is safer than consuming unlimited gateway memory.
Ephemeral events such as typing indicators can simply be dropped under pressure.
Authentication is not enough; authorize the conversation
Every operation must verify membership/permissions:
- open conversation
- send message
- subscribe to realtime room
- edit/delete message
- download attachment
- fetch history
Never accept:
conversation_id supplied by client
and assume authentication means the user belongs there.
This is especially important with WebSocket subscriptions, where one authorization mistake can continuously leak future messages.
Session revocation must reach long-lived connections
A WebSocket can remain open after:
- password reset
- account disabled
- role removed
- session revoked
Design a way to close or re-authorize existing connections when high-risk identity state changes.
Do not rely only on token validation performed hours earlier during the opening handshake.
Moderation belongs in the message pipeline
Depending on the product, chat may need:
- user blocking
- abuse reports
- spam/rate limits
- file scanning
- banned-content policies
- moderator actions
Keep moderation decisions auditable and scoped.
For example, “user A blocked user B” should be enforced before accepting/fanning out the message—not only hidden on A's UI after delivery.
Push notifications are an offline alert, not message storage
When no active session exists, send a mobile/web push alert where preferences allow it.
The push payload can contain:
- conversation ID
- message ID
- safe short preview when privacy settings permit
When the user opens the app, fetch the canonical message/history from the chat API.
Do not depend on push providers to preserve the conversation transcript.
Search is a separate access pattern
Conversation history is usually optimized for:
conversation_id + sequence/time
Full-text search asks a different question:
find messages containing X across conversations I can access
At modest scale, the primary database may handle indexed search.
At larger scale, a dedicated search index can be appropriate—but authorization filtering and deletion propagation must remain correct.
Search should never expose messages from conversations the user cannot access.
Observability should follow the message lifecycle
Useful metrics include:
- message-create latency
- durable-write failure rate
- duplicate/idempotent retry rate
- realtime fan-out latency
- active connections
- reconnect frequency
- history catch-up size
- per-connection queue depth
- slow-client disconnects
- push fallback count
- attachment upload failures
- hot rooms/conversations
A “WebSocket connections active” graph alone does not tell you whether messages are durable or synchronized correctly.
A practical send/reconnect protocol
Send
- Client creates
client_message_id. - Sends request/event with conversation ID and content.
- Server authenticates and verifies membership.
- Server validates/rate-limits.
- Server inserts idempotently and assigns canonical sequence.
- Server acknowledges sender.
- Fan-out sends committed message to active authorized sessions.
- Offline destinations can receive push alerts.
Reconnect
- Reauthenticate connection.
- Client sends its last known sequence/cursor.
- Server returns missing durable messages.
- Client deduplicates by canonical message ID.
- Realtime stream resumes from a defined boundary.
- Read/unread cursors are reconciled from server state.
If those two flows are correct, much of chat reliability becomes manageable.
Production checklist
Before launching chat, verify:
- messages become durable before normal fan-out
- client retries are idempotent
- server assigns canonical per-conversation ordering
- reconnect can recover every durable gap
- synchronization has a race-safe cursor boundary
- unread/read state is server-reconcilable
- one user can have multiple sessions/devices
- presence expires rather than staying permanently “online”
- typing indicators are ephemeral and rate-limited
- edits carry versions or equivalent ordering
- deleted messages have defined synchronization semantics
- attachment storage is separate and authorized
- slow connections cannot consume unbounded memory
- conversation membership is checked on every sensitive path
- session revocation can terminate live connections
- push is only an alert channel, not message history
A dependable chat system is built around durable ordered conversation state with realtime delivery layered on top. Once that distinction is clear, reconnects, retries, multiple devices, and provider failures stop being special cases and become normal parts of the protocol.
References
BUILDING A SAAS PRODUCT?
Explore Lofingo’s end-to-end SaaS platforms, multi-tenant cloud architecture, and high-concurrency backend engineering.
EXPLORE SAAS SERVICES →