Architecture Deep Dive
This page explains how a WhatsApp-style messaging system actually behaves under retries, gaps, duplicates, offline devices, and regional failure. The running example is consistent throughout: Alice sends message M to Bob; Bob's laptop is online, Bob's phone is offline, and Alice loses the first ACK after the server has already durably accepted the message.
WebSocket Gateway Internals
The gateway owns connection lifecycle, not message correctness. It authenticates a device session, enforces socket-level backpressure, and gives the rest of the system a stable way to talk to a currently connected client.
On connect, the gateway verifies the client token, establishes a device or session identity, allocates a server-side connection_id, and registers liveness metadata. When Alice reconnects after losing her ACK, she does not resume an existing TCP connection; she creates a new session that later recovers state through sync.
Heartbeats refresh a lease. Missing heartbeats eventually mark the connection stale, but the lease window is deliberately wider than one missed ping so transient mobile-network jitter does not cause flapping. Gateways also support graceful draining: stop admitting new sockets, tell clients to reconnect with jitter, and let healthy gateways absorb them gradually.
- Persistent connection management is a connection-plane concern, not the durable write contract.
- Reconnect migrates sessions; live TCP connections are not moved between gateways.
- Bounded socket buffers are part of correctness because unbounded buffering turns one slow client into a fleet-wide memory risk.
Connection Registry & Multi-Device Routing
A user is not one connection. The routing model is user_id -> {device/session -> gateway, connection_id, epoch} so delivery can target several active devices and safely ignore stale entries.
The registry is ephemeral routing state refreshed by connect, heartbeat, disconnect, and session replacement. When Bob opens his laptop and phone, the messaging service may get two live routes. If the phone is offline, the registry simply has no live phone route; that does not change durable message state.
Use a gateway epoch or session version so the newest connection wins when an old socket lingers. If a route points to a dead socket, the target gateway rejects it, the sender-side service re-looks up routing, and if no live session remains, the system falls back to offline sync and optional push hints.
- Model routing per device or session, not per user.
- Registry entries need TTLs or leases plus epoch-aware replacement.
- A realtime lookup failure does not mean the message was lost; it remains in durable storage for sync.
Message Identity & Idempotent Send
A retry is expected behavior, not an exception. Alice creates client_message_id = cmid-123 before the first send attempt so every later retry still refers to the same logical message.
The server can then map (sender_id, sender_device_id, client_message_id) to one canonical server message_id, one acceptance record, and one sequence assignment. If the first attempt persists successfully but the ACK is lost, the second attempt should reconstruct the original result instead of creating a second bubble in Bob's chat.
client_message_id = cmid-123
first attempt -> persist message_id = msg-9001, sequence = 418
ACK lost
retry with same client_message_id -> return message_id = msg-9001, sequence = 418The key requirement is atomicity: do not do a racy read-before-write existence check. Use a conditional insert, uniqueness constraint, transactional record, or equivalent mechanism so two concurrent retries cannot both "see nothing" and both insert.
- Keep request identity client-generated and stable across retries.
- Separate client request identity from server message identity.
- Duplicate-safe UI rendering depends on stable IDs plus idempotent server processing.
Message Write State Machine
The message lifecycle is not one mutable row that everything updates in lockstep. It is a sequence of events that advance user-visible state monotonically.
CLIENT_PENDING -> SERVER_ACCEPTED/DURABLE -> DELIVERY_PENDING
-> DELIVERED(to recipient product semantic) -> READAlice's local client starts at CLIENT_PENDING. After the server durably accepts M, Alice may show a single checkmark. That does not imply Bob has received anything yet. DELIVERY_PENDING covers the period where Bob might be online, offline, partially connected, or reachable only on one device.
This event-oriented state model lets the system survive partial failure. A recipient delivery event can be duplicated, delayed, or batched without violating correctness as long as updates remain idempotent and monotonic.
Message Ordering & Sequencing
Users need ordering inside a conversation, not across the whole product. The hard part is deciding which component has write authority for that conversation when senders race or owners fail.
Several implementations are reasonable: an ordered log partition keyed by conversation, a conversation owner or sequencer with a lease and fencing token, or a storage-side conditional sequence allocator. What they share is serialization of accepted writes for one conversation. Consistent hashing can route traffic toward one owner, but it does not by itself prove that only one owner is active during rebalance or failover.
Approximate time-sortable IDs reduce coordination but do not guarantee strict contiguous order under concurrency. If the product promise is strict per-conversation sequencing, some ordering authority must assign it before messages are treated as accepted.
- Ordering scope is conversation-level.
- Ownership transfer requires fencing or another stale-owner rejection mechanism.
- Time-sortable IDs and strict contiguous sequence numbers solve different product promises.
Message Gap Recovery
Bob's laptop can legitimately receive sequence 420 before 419 because the transport and fan-out layers retry independently. The client must detect the gap without assuming the backend is broken.
The client keeps the highest contiguous sequence it has safely applied. If it receives 420 while waiting for 419, it can buffer 420 within a bounded window, request the missing range, and continue once 419 arrives. If the gap is large or the repair times out, it escalates to a conversation sync or a device sync cursor replay.
Do not hard-code a universal wait like “sleep 500 ms.” The right bound depends on network conditions, device memory, and product tolerance for temporarily withholding a newer message until the missing one is repaired.
- Gap detection uses expected sequence numbers, not timestamps.
- Buffer out-of-order arrivals only within bounded memory.
- Repair can escalate from targeted range fetch to full conversation or device sync.
Message Storage & Partitioning
Message storage needs fast append, ordered history reads, and bounded physical partitions. A logical conversation should not become one forever-growing physical shard.
A practical schema is (conversation_id, bucket) as the physical partition key and sequence or time within the bucket as the clustering key. Buckets can be time-based, sequence-range based, or size-based. This preserves ordered scans while preventing one long-lived or high-volume conversation from creating an unbounded partition.
Reads page naturally: start at the newest bucket and sequence, then walk backward as the user scrolls. For Alice and Bob's direct conversation, the bucket abstraction is invisible to the product, but it protects compaction, repair, and hotspot behavior underneath.
- Bucketed partitions solve physical growth, not ordering by themselves.
- Choose bucket boundaries from write rate and history-read patterns.
- Current-bucket hot spots still need operational mitigation.
Conversation Log vs Inbox
The source of truth question matters: do you store one canonical conversation log, one per-user inbox, or both?
Conversation log
Store each message once under the conversation. In a plaintext-at-the-server design this minimizes write amplification and keeps ordering natural. History reads are straightforward, but delivery and sync need additional indexes or cursors. With end-to-end encryption, the sender encrypts the payload separately for each recipient device, so the server typically stores one encrypted envelope per recipient device, tied to the same conversation, sequence number, and message ID. The conversation log then holds ordering and metadata, while payload storage grows with the number of devices. Exact layouts vary by design.
Per-user or per-device inbox
Materialize work per recipient. This simplifies unread and catch-up queries, but group messaging amplifies writes dramatically because one group message may create thousands of inbox entries.
Hybrid
Use the conversation log as the durable message source of truth and maintain per-user or per-device delivery indexes for sync, unread, or notification work. This is a common middle ground for messaging products.
For this module, the clean baseline is hybrid: one durable conversation log plus a sync-oriented change stream or cursor index. That keeps Bob's laptop and phone convergent without turning every message into a fully duplicated inbox write.
- Say explicitly which store is canonical.
- A hybrid model is often easier to reason about than forcing every feature into either pure log or pure inbox semantics.
Offline Message Sync
Offline recovery should start from a durable cursor, not from scanning every conversation the user ever joined.
When Bob's phone reconnects hours later, it presents its last durable device cursor. The server replays missed message, membership, and receipt events after that cursor, deduplicates naturally by stable IDs, and advances the cursor only after the device safely applies the batch.
Conversation range queries are still useful when Bob opens one specific thread or repairs a localized gap, but reconnecting a device with hundreds of conversations should not require N independent scans just to discover which threads changed.
- Use a user- or device-scoped durable cursor for reconnect.
- Cursor advancement happens after safe apply, not before.
- Expired cursors require a heavier but explicit reconciliation path.
Message Delivery Receipts
“Delivered” is a product semantic, not a transport fact. You have to define what event actually turns Alice's UI from one checkmark to two.
A strong delivery event is device-level acknowledgement after the recipient app has received, validated, and safely enqueued or persisted the message locally. Merely handing bytes to Bob's gateway is weaker; the gateway still might disconnect before Bob's app processes them.
This module uses the explicit product rule: user-visible delivered means at least one active recipient device acknowledged receipt. Internally, the system may still track per-device delivery state for later sync and convergence.
- Delivery receipts are downstream events from the durable write.
- Per-device tracking can exist even when the UI exposes a per-user aggregate.
- Duplicate or late delivery acks must be idempotent.
Message Read Receipts
Read state is usually compressible. Bob does not need to emit one receipt event for every historical message when he opens the chat on his laptop.
A conversation-level high-watermark like read_up_to_sequence = 418 is often enough. It says Bob has read every message up to 418 on behalf of the chosen product semantic. This is far cheaper than sending hundreds of individual read events and is naturally monotonic.
Large groups may suppress, aggregate, or omit individual read receipts entirely because the state and fan-out explode. The storage model should reflect that by avoiding one mutable per-message status column that pretends all conversations have the same receipt shape.
- Prefer monotonic read markers where the product allows it.
- Group read semantics should be explicitly scoped; they are not free at very large audience sizes.
Group Membership Consistency
Group messaging is not just fan-out. The system must also decide who counts as a member at the instant a message is accepted.
Keep a durable membership source of truth plus a membership version or snapshot concept. On send, authorize the sender against current membership and bind the accepted message to the membership snapshot or version used for fan-out. That gives a deterministic answer when a remove and a send race each other.
If Carol is removed at version 91 while Alice's send is accepted against version 90, the product can intentionally decide whether Carol should still get that message. The important part is that the decision is explicit and replayable, not left to timing luck across multiple workers.
- Membership changes are durable control-plane events.
- Fan-out should know which membership snapshot it is using.
- Concurrent send/remove races need product semantics, not hand-waving.
Group Message Fan-Out
Sender ACK should depend on durable acceptance, not on waiting for a huge group fan-out to finish.
Small or moderately active groups may use mostly synchronous routing because the member set is small enough to resolve quickly. Larger or hotter groups often switch to async fan-out workers so Alice still gets a prompt ACK while background workers handle recipient-by-recipient delivery, retries, and offline fallback.
The threshold is workload-dependent. Group size, online fraction, activity level, receipt semantics, and gateway pressure all matter, which is why hard-coded “256 means write fan-out forever” rules age badly.
- Separate sender acceptance latency from group delivery completion.
- Use adaptive fan-out based on actual workload, not mythology.
Broadcast / Channel Messaging
A broadcast channel is not just “a really big group.” It behaves more like a write-once feed with audience fan-out and caching concerns.
Broadcast or channel products typically store once, maintain an audience membership index, and let recipients fetch or be notified rather than pretending every subscriber needs per-message synchronous delivery state. Few publishers write; a huge audience reads.
That changes several expectations: per-recipient read receipts are usually omitted, ordering is easier because there are few senders, and read-time caching matters more than per-subscriber realtime certainty.
- Separate channel semantics from interactive group semantics.
- Broadcast architectures often prefer store-once plus notification or read-time fetch.
Presence Architecture
Presence is ephemeral lease state derived from active sessions. It should be approximate, privacy-aware, and selectively fanned out.
A practical rule is: user is online if any allowed session currently holds a live lease; otherwise show offline or last seen according to privacy settings. Heartbeats refresh TTLs, but the service should fan updates only to explicitly interested viewers such as users who are actively looking at that conversation.
That means presence failure should not block message delivery. Alice can still send M even if the product temporarily cannot say whether Bob is “online now.”
- Presence is a hint, not a transaction.
- Aggregate across devices according to product policy, often “online if any active session”.
Typing Indicators
Typing is the clearest example of lossy ephemeral messaging: useful when fresh, worthless when late, and never worth durable storage.
Typing start and stop events should be rate-limited, coalesced, and TTL-based. If Bob closes his laptop or the packet is lost, the indicator simply expires. There is no replay backlog of “Bob was typing 20 minutes ago.”
This is exactly the kind of traffic the platform can drop during overload long before it considers degrading accepted user messages.
- Typing indicators need short TTLs and can be safely dropped.
- Never mix typing semantics with durable message semantics.
Offline Push Notifications
Push is a wake-up path for clients that lack a usable realtime route. It is not where message correctness lives.
The safe order is: durably accept the message, fail to find a usable live route for Bob's phone, enqueue a push hint, then let the phone reconnect and sync authoritative state. A delayed or duplicated push may change wake-up latency, but not whether M exists.
When end-to-end encryption is enabled, the push payload must also respect metadata and plaintext leakage constraints. Usually that means a minimal hint instead of the full message body.
Media Message Architecture
Media should share conversation semantics but not the byte path. Upload and download are separate from the realtime messaging lane.
The client uploads bytes directly or resumably to object storage through an authorized media service, then sends a tiny message containing a media reference and metadata. Recipients fetch through an authorized object or CDN path. That keeps the text and control-message pipeline small and predictable.
With E2EE, media may be encrypted client-side before upload, and the message reference carries the information recipients need to fetch and decrypt it. The storage layer still sees bytes and metadata, but not cleartext content.
- Never ship large media blobs through the gateway messaging path.
- Media correctness still depends on authorization and lifecycle, but not on WebSocket throughput.
End-to-End Encryption Impact
End-to-end encryption changes what the server can inspect, store, and derive. It does not remove the need for routing, device management, or metadata handling.
At architecture level, the server stores and routes ciphertext plus metadata, while key material and cryptographic session state live primarily on clients. Multi-device support gets harder because each linked device often has its own cryptographic identity or session state, and adding or removing a device changes who can decrypt future content.
Group messaging gets harder too: membership changes interact with key distribution, message history, and who is allowed to decrypt. Server-side content search, indexing, moderation, and backup options become constrained, which is why this section matters even without turning into a protocol tutorial.
- Keep E2EE discussion at architecture-impact level.
- Keys on clients and device management on the server create new operational coupling.
Event Stream / Kafka Role
Streaming infrastructure is useful when it cleanly separates synchronous acceptance from asynchronous side work. It becomes dangerous when the boundary is fuzzy.
A messaging event stream can power async group fan-out, push notification triggering, receipt propagation, analytics, and abuse workflows. But a design should state clearly whether those features degrade when the stream is unavailable. If large-group fan-out depends on Kafka, large-group delivery is degraded during Kafka outage.
For events that must survive publication failure, use a durable handoff such as a transactional outbox or store-backed retry record. In-memory producer buffers are for smoothing short hiccups, not for durable outage recovery.
- Define the sync/async boundary explicitly.
- Outbox-style durability is different from producer-side buffering.
Backpressure & Slow Consumers
A messaging system must be willing to disconnect a slow client. Otherwise one stalled socket quietly turns into an infrastructure incident.
Gateways need bounded per-connection queues, write deadlines, and pressure signals back toward admission control. If Bob's laptop stops reading, the gateway should eventually drop that socket and rely on offline sync after reconnect rather than retaining arbitrary pending bytes in memory.
Backpressure also applies upstream. During overload, the system should protect durable accepted messages first, then defer or drop ephemeral work such as typing, some presence updates, and maybe batched analytics.
- Bound queues per socket and per service.
- Slow-consumer handling is part of correctness because sync is the fallback safety net.
Message Edit, Delete & Reactions
Edits, deletes, and reactions are useful extensions because they reveal how the system handles derived events that reference an existing stable message ID.
Treat each mutation as its own ordered event against a target message_id. Edits may carry a new version, delete-for-self and delete-for-everyone need distinct product semantics, and reactions can often be aggregated because they are high-frequency overlays rather than new core content.
There are also hard limits: if Bob saw the original plaintext on one device already, delete-for-everyone cannot mean “nobody ever saw it.” E2EE and retention policies further constrain what the server can enforce retroactively.
- Stable message IDs make later mutations addressable.
- Mutation guarantees are product semantics layered on top of durable message history.
Multi-Region Messaging
The clean baseline is conversation home-region write authority with cross-region delivery. Start there before discussing active-active writes.
In that model, each conversation has one write authority region or owner at a time. Users may connect to nearby gateways globally, but accepted writes for that conversation serialize through its current authority, preserving the ordering contract. Other regions can cache, deliver, and replicate, but they do not independently assign competing sequence numbers for the same conversation.
Active-active multi-region writes are possible only if you also define conflict or global-order semantics, lease ownership, or stronger consensus around who is allowed to assign order. During partition, two regions believing they own the same conversation is the classic split-brain corruption case.
- Single conversation authority is the simplest durable ordering story across regions.
- Cross-region delivery is easier than cross-region concurrent write authority.
- Regional design should explicitly discuss partition behavior and split-brain prevention.
What to remember across all 23 sections
- Separate the planes: connection handling, the durable write contract, delivery, sync and ephemeral signals (presence, typing) each have their own guarantees and failure modes.
- Identity and order are explicit: client-generated stable IDs make sends idempotent, and ordering is a per-conversation sequence, with gaps detected by sequence number rather than timestamps.
- Durable write first, realtime second: routing to online devices is best-effort, and a user- or device-scoped cursor repairs whatever routing missed on reconnect.
- Bound everything that can grow: queues per socket and per service, fan-out for large groups, and reconnect and receipt bursts.
- Name the authority: say which store is canonical and which region assigns order for each conversation, and how split-brain is prevented.