Designing Delivery Confirmation: Read Receipts and ‘Delivered’ Status at Scale
A production-grade system design deep dive into building sent / delivered / read status tracking for a messaging platform — balancing accuracy, latency, privacy, and the network chatter cost of keeping millions of clients continuously informed.
Introduction & History — Foundations
The single gray checkmark, the double gray checkmark, and the double blue checkmark are, per-message, some of the most information-dense two-pixel icons in modern software. In the space of a tiny status indicator, a messaging platform is communicating a precise claim about the physical state of a byte string somewhere on a stranger’s phone: it left your device, it arrived at the server, it reached the recipient’s device, and — if read receipts are enabled — a human being actually looked at it. Getting that claim right, consistently, across unreliable mobile networks, offline devices, multiple simultaneous devices per user, and group chats with hundreds of members, turns out to be a surprisingly deep distributed-systems problem.
Delivery confirmation predates modern chat apps by decades. Email’s read receipt mechanism (via the Disposition-Notification-To header, standardized in RFC 8098) was one of the earliest attempts at this, but it was famously unreliable — mail clients could ignore the request entirely, and the feature was widely considered spammy and untrustworthy. SMS took a different approach: telecom carriers implemented delivery reports at the protocol level (SMS-STATUS-REPORT), giving a sender a “delivered” confirmation from the carrier network itself, though never true read confirmation, since carriers have no visibility into whether a human opened a text.
Modern OTT (over-the-top) messaging platforms — BlackBerry Messenger (BBM) is widely credited as the app that popularized the “D” and “R” delivery/read indicators for a mass consumer audience in the late 2000s — reinvented this problem for an always-connected, app-controlled context, where the platform itself (not the carrier) owns both endpoints and can build a much richer, more accurate status pipeline. WhatsApp’s checkmarks, iMessage’s “Delivered”/“Read” text, and Telegram’s checkmark pair are all descendants of this same idea, each making slightly different trade-offs between accuracy, user privacy, and — the focus of this tutorial — how much network chatter it costs to keep that status accurate in near real time across a platform with billions of messages sent per day.
“Why is delivery confirmation harder to design well than it looks from the outside?” A strong answer: because it looks like “just update a field,” but it’s actually a distributed state-tracking problem across at least three independent, unreliable components — the sender’s connection, the server, and the recipient’s device (potentially several recipient devices, and in a group chat, many recipients) — each of which can be offline, slow, or duplicating events at any moment. On top of that, every status transition is a candidate for a network message back to the sender, and naively sending “delivered” and “read” events per-message per-recipient in real time can multiply total network traffic on the platform by a large factor. The core design tension is exactly what the prompt names: accuracy of the status shown to the user, versus the chatter cost of achieving that accuracy — and the right answer is almost always “good enough accuracy, heavily batched,” not “perfect accuracy, unbatched.”
Problem Framing & Requirements — What We’re Building
2.1 Functional Requirements
- A sender can see, per message, whether it is: sent (left the sender’s device and was accepted by the server), delivered (reached at least one of the recipient’s active devices), and read (the recipient viewed it, if read receipts are enabled).
- In a group chat, the sender can see an aggregate view — e.g., “delivered to 8 of 10,” “read by 6 of 10” — and, optionally, a per-member breakdown.
- Status must correctly account for a recipient having multiple active devices (phone, tablet, web/desktop client) — a message is “delivered” once any device receives it, but “read” semantics need a clear, consistent policy across devices (see 4.4).
- Users can disable read receipts (and, on many platforms, doing so also disables seeing others’ read receipts — a reciprocal privacy design, discussed in the security section).
- Status updates must eventually reach the sender even if the sender was offline at the moment the recipient read the message — i.e., the receipt itself needs reliable, not just best-effort, delivery back to the sender.
2.2 Non-Functional Requirements
Scale
Tens of billions of messages sent per day platform-wide; each message can generate up to several status transition events (sent → delivered → read) per recipient, per device — a naive implementation multiplies total event volume several-fold over the raw message volume.
Latency
“Delivered” status should typically reflect reality within a second or two of actual delivery; “read” status has looser latency tolerance and is the primary target for batching, since a few seconds of delay on a read receipt is imperceptible to users but saves enormous aggregate network load.
Network efficiency
Minimize redundant acknowledgement traffic — this is the central non-functional requirement named directly in the problem statement, and it should shape nearly every design decision below.
Monotonic consistency
Status should never move “backward” in the eyes of the sender (a message should never appear to un-deliver or un-read), which requires careful handling of out-of-order or duplicate events.
Battery & mobile data efficiency
Especially for the recipient’s device, generating and transmitting many small acknowledgement packets is a meaningful battery and data cost at mobile scale, and needs to be minimized through batching and piggybacking.
Privacy
Read status is sensitive personal information (whether or not someone has seen a message, and often when) and needs to be governed by explicit user consent and controllable settings.
“The prompt asks you to balance accuracy against network chatter. Can you frame that trade-off concretely — what does ‘too much chatter’ actually cost?” A strong answer: concretely, if every single message triggers an immediate, individual “delivered” acknowledgement and a separate immediate “read” acknowledgement, each recipient device generates roughly two extra network round-trips per message received. At a platform sending tens of billions of messages a day, that’s tens of billions of extra small packets — each one waking a mobile radio, consuming battery, consuming metered mobile data for users on constrained plans, and adding write load to whatever backend system persists and fans out status updates. The chatter cost isn’t abstract; it shows up as real infrastructure cost (extra WebSocket/push traffic, extra database writes) and real user-facing cost (battery drain, data usage) that scales linearly with message volume unless deliberately dampened through batching, debouncing, and piggybacking acknowledgements onto other traffic that’s already flowing.
Architecture & Components — Blueprint
Figure 1 — End-to-end architecture. Blue lines are message and control flow, green lines are persistence and cache paths, red lines are privacy/auth gates, purple dashed lines are multi-device and group fan-out flows. The bulkhead at the bottom is the invariant that keeps status failures from ever becoming messaging failures.
3.1 Core Components
| Component | Responsibility |
|---|---|
| Connection Layer / API Gateway | Manages persistent connections (WebSocket, MQTT, or long-poll) for online clients, and routes requests for offline clients toward push notification delivery. |
| Messaging Service | Owns the message send path; on accepting a message, immediately marks it “sent” and persists it as the source of truth, independent of downstream delivery/read tracking. |
| Delivery Service | Responsible for actually getting the message payload to the recipient’s device(s) — via an active persistent connection if online, or via a push notification wake-up if offline — and observing the resulting device-level acknowledgement. |
| Status Aggregation Service | The heart of this design: collects raw per-device delivered/read events, batches and debounces them, deduplicates, and computes the aggregate status (especially for groups) before it’s ever sent back to the sender. |
| Status Store | A dedicated, write-optimized store holding per-message, per-recipient, per-device status state — separate from the message content store, since its access and update patterns are completely different (frequent small updates vs. write-once content). |
| Push Notification Provider | External platform push services (Apple APNs, Google FCM) used to wake a recipient’s app when it has no active persistent connection, which is also the trigger point for delivery acknowledgement in the offline case. |
“Why introduce a separate Status Aggregation Service instead of just having the Messaging Service handle status updates directly, the way it handles message content?” A strong answer: message content and delivery status have almost opposite access patterns and scaling needs. A message is written once and read many times, with strong durability requirements — losing a message is unacceptable. A status update, by contrast, is written frequently (every device, every state transition), is inherently more tolerant of batching and even occasional loss (a slightly-delayed “read” indicator is a UX nuance, not data loss), and benefits enormously from debouncing logic that has no equivalent on the content side. Coupling them into one service would force one component to serve two very different reliability and throughput profiles, making it harder to independently scale, batch, and optimize the much higher-volume, much more loss-tolerant status stream without risking the correctness guarantees the message content path actually needs.
Internal Working — Under the Hood
4.1 The Status State Machine
Each message, for each recipient, moves through a small, strictly-ordered state machine. Modeling this explicitly as a state machine — rather than as loosely-typed boolean flags — is what prevents the classic bug of a message appearing to “un-deliver” when events arrive out of order.
Figure 2 — Message status state machine. Forward transitions are strictly monotonic (blue → green → amber). Duplicate acknowledgements loop back as idempotent no-ops. The red edge is the terminal failure branch.
The critical invariant enforced by the aggregation logic is monotonicity: status can only move forward (Sent → Delivered → Read), never backward, regardless of the order in which raw events physically arrive at the server. If a “read” event somehow arrives before a “delivered” event was fully processed (plausible under network reordering, since these are typically sent as independent messages over possibly different connections or retried separately), the aggregation service should still resolve the final state to “read” and treat the missing “delivered” event as implied, rather than getting stuck or, worse, showing “delivered” after the sender already correctly saw “read.”
4.2 Batching and Debouncing: The Core Chatter-Reduction Technique
This is the crux of the entire design problem. Two closely related but distinct techniques do most of the work:
- Batching: rather than sending one network message per status event, the recipient’s client (and the server’s aggregation layer) accumulate multiple status transitions over a short window and send them together as a single batch. If a user opens a chat and reads 15 unread messages in the span of two seconds, that should produce one batched “read up to message #N” acknowledgement, not 15 individual ones.
- Debouncing: waiting for a short quiet period (typically a few hundred milliseconds to a couple of seconds) before emitting a status update, so that rapid-fire changes collapse into a single final update rather than emitting one update per intermediate state.
The single highest-leverage design decision here is using a “read up to” cursor rather than per-message read acknowledgements. Instead of the recipient’s client saying “message 1042 was read,” “message 1043 was read,” “message 1044 was read” as three separate events, it says “everything up to and including message 1044 has been read” as one event. This collapses what would be O(n) acknowledgements for n messages read in a session down to O(1) — a single cursor update — and is the single most impactful chatter-reduction technique in the entire design, because in practice users very often read messages in contiguous batches (opening a chat and scrolling through everything since their last visit) rather than one at a time.
This “read up to” cursor approach is exactly how IMAP and most modern chat platforms track read state efficiently — rather than a per-item flag requiring an update for every single item, a single monotonically increasing sequence number per conversation, synced occasionally, captures the read boundary. It’s a small design choice with an outsized impact on total system chatter, and it’s the kind of detail that separates a naive design from a production-grade one in an interview.
4.3 Piggybacking Acknowledgements
A second major technique is piggybacking: rather than emitting a dedicated network round-trip purely to carry a delivered/read acknowledgement, attach that acknowledgement to other traffic that’s already flowing over the same persistent connection — for example, riding along on the client’s periodic connection heartbeat/keepalive, or bundling it with the client’s own next outbound message in that conversation. This further reduces the marginal network cost of acknowledgements toward zero in the common case where the connection is already being kept warm for other reasons.
4.4 Multi-Device Semantics
Modern messaging users routinely have multiple simultaneously-linked devices (phone, tablet, desktop/web client). This complicates both “delivered” and “read” semantics:
- Delivered is typically defined as “delivered to at least one active device” — the first device to successfully receive the message triggers the delivered status, and the aggregation service does not wait for or require every device to acknowledge.
- Read is more subtle. Common policy is “read on any device counts as read everywhere” — if a user reads a message on their phone, the desktop client’s unread badge should also clear, and the sender should see “read.” This requires the platform to synchronize read state across a user’s own device fleet (a separate, smaller-scale sync problem nested inside the larger one), typically via a per-user “read cursor” broadcast to all of that user’s own linked devices whenever it advances.
Data Flow & Lifecycle — Journey of a Status Update
Figure 3 — Sequence diagram. Blue lines are forward events, green dashed lines are acknowledgements/status pushes. Note how the “Sent” ack returns to the sender before the recipient device even receives the message — the sender-facing status stream is deliberately decoupled from actual downstream delivery timing.
5.1 Write Path for Status Events
- Recipient device receives a message and, on successful local persistence, schedules a delivered-acknowledgement (often near-immediate, since delivered status is comparatively cheap and high-value for sender UX).
- Delivered acknowledgements are lightweight enough that they are typically sent close to real time (sub-second to low-single-digit-second delay) rather than heavily batched, since there’s usually only one such event per message and batching gains are smaller here than on the read side.
- When the user actually views messages, the client updates its local “read up to” cursor and starts a debounce timer (commonly a few hundred milliseconds) rather than sending immediately, so that rapid scrolling through many messages collapses into a single cursor update.
- The cursor update is sent to the Status Aggregation Service, which persists the new read boundary, updates the message-level status for all messages at or before that boundary, and — for the sender’s benefit — schedules a batched status notification rather than notifying the sender per-message.
- For group chats, the aggregation service additionally maintains a per-recipient delivered/read cursor and computes the aggregate “delivered to X of Y / read by X of Y” summary that the sender’s client actually renders, rather than the sender’s client having to compute this itself from N individual streams.
5.2 Lifecycle of a Status Update Under Failure
If the recipient’s connection drops before an acknowledgement is sent, the acknowledgement is simply lost from the network’s perspective — but because the true read/delivered state lives durably on the recipient’s own device (it actually did receive/read the message), the client re-sends its outstanding acknowledgements as soon as it reconnects, using the same “up to cursor N” idempotent model, so a dropped connection causes at most a delay, never an incorrect final state. This idempotency is what makes at-least-once delivery of acknowledgement events safe: duplicate delivery of the same cursor update is a harmless no-op on the server, since applying “read up to message 1044” twice has the same effect as applying it once.
Advantages, Disadvantages & Trade-offs — Balancing Act
| Design Choice | Advantage | Disadvantage / Trade-off |
|---|---|---|
| “Read up to” cursor instead of per-message read events | Collapses many acknowledgements into one; dramatically reduces chatter | Loses fine-grained “which exact message was read first” ordering detail, which is rarely needed anyway |
| Debouncing read receipts | Large reduction in event volume during active reading sessions | Introduces a small, deliberate latency (hundreds of ms to a couple seconds) before the sender sees “read” |
| Immediate (non-batched) delivered acknowledgement | Sender gets fast, high-confidence feedback that the message actually reached a device | Higher per-message chatter cost than the read path; a deliberate exception to the “batch everything” default |
| Piggybacking on existing connection traffic | Near-zero marginal network cost for many acknowledgements | Adds coupling/complexity between unrelated subsystems (heartbeat logic and status logic) |
| Per-message status vs. per-recipient aggregate only | Per-message gives fine detail (useful in 1:1 chats) | Doesn’t scale to large groups; aggregate-only view is necessary there, losing per-member detail by default |
“Why treat ‘delivered’ and ‘read’ acknowledgements so differently — sending delivered near-immediately but batching read receipts aggressively?” A strong answer: because their value-per-event and volume profile are different. Delivered status has high sender-perceived value (it’s the primary reassurance that a message actually reached the other side, which matters most right after sending) and occurs exactly once per message per device, so there’s no batching opportunity to exploit — you can’t collapse multiple delivered events into one, since there’s only one. Read events, by contrast, very commonly occur in bursts (a user opening a chat and reading a dozen unread messages at once), which is exactly the pattern where a “read up to” cursor and debouncing yield large real savings without meaningfully hurting user experience, since a few hundred milliseconds of delay on a read receipt is imperceptible while the equivalent delay on delivered status would feel sluggish for the message that was just sent.
Section takeaway
Every trade above swaps one axis for another — latency for freshness, granularity for scale, complexity for near-zero marginal cost. The design is a portfolio of small, bounded, tunable choices rather than one big absolute decision.
Performance & Scalability — Scale
7.1 Estimating Chatter Volume
Consider a platform sending 20 billion messages per day. A naive, unbatched design generates roughly one delivered event and one read event per message per recipient — for 1:1 chats alone, that’s up to 40 billion extra small network events per day, on top of the 20 billion message sends themselves, effectively tripling total message-layer traffic. Applying the “read up to” cursor with debouncing, informed by realistic usage patterns (users commonly read 5–20 messages per chat-opening session as one burst), can plausibly collapse the read-side volume by an order of magnitude or more, since a session that would have generated 15 individual read events instead generates one cursor update. This is the kind of estimate worth walking through explicitly in an interview to justify why batching isn’t just a nice-to-have but a load-bearing scalability requirement.
7.2 Group Chat Fan-Out Cost
Status tracking cost in a group chat scales with the number of members, not just the number of messages, since each member independently generates delivered and read events for each message. A 500-member channel where everyone eventually reads every message could generate 500 status events per message if handled naively — completely impractical at scale. Production systems address this by (a) aggressively batching and coalescing per-member status into periodic summary updates rather than individual pushes, (b) for very large groups, sometimes dropping granular read-receipt tracking altogether in favor of a much coarser signal (e.g., “seen by 240+ people” rather than an exact, continuously-updated count), and (c) rate-limiting how frequently the aggregate summary is recomputed and pushed to the sender — e.g., at most once every few seconds, rather than on every single incoming status event.
7.3 Server-Side Batching of Outbound Status Pushes to the Sender
Beyond reducing chatter from the recipient side, the server itself should batch outbound status notifications to the sender: rather than pushing a fresh status update to the sender’s device the instant any underlying event arrives, the Status Aggregation Service can accumulate status changes for a short window (a common pattern is a small fixed window, e.g., 500ms–2s) and flush a single combined update, which matters enormously for active senders in busy group chats where dozens of read events might otherwise trigger dozens of separate pushes to the sender’s device in quick succession.
“How would you handle a viral broadcast-style message sent to millions of recipients (e.g., a channel with a huge subscriber base) without the read-receipt system falling over?” A strong answer: at that scale, per-recipient granular read tracking sent back to the sender in real time isn’t just expensive, it’s not even useful — no UI could meaningfully render “read by 2,847,193 of 5,000,000” updating live. The right approach is to decouple raw event ingestion (which still needs to reliably count reads, e.g., via a scalable counting mechanism like an approximate distributed counter or a periodically-flushed aggregation pipeline) from what’s actually surfaced to the sender, which should be a coarse, periodically-refreshed approximate count (updated every several seconds to minutes, not per-event) rather than a live per-recipient stream. This is a case where the product requirement itself should shift — from “know exactly and instantly” to “know approximately and eventually” — once volume crosses a threshold where exact real-time tracking stops being a reasonable use of infrastructure.
7.4 Back-of-the-Envelope: Sizing the Status Store
Walking through rough numbers again clarifies why the cursor model matters so much operationally. Suppose a platform has 500 million daily active users, and each user is, on average, an active participant in 15 conversations per day, opening each conversation roughly 4 times daily (checking for new messages throughout the day). With a naive per-message acknowledgement model, if the average conversation-opening session involves reading 6 unread messages, that’s 500M × 15 × 4 × 6 ≈ 180 billion individual read events per day. With the cursor model, that same activity collapses to at most 500M × 15 × 4 ≈ 30 billion cursor updates per day — and in practice considerably fewer, since debouncing further coalesces rapid successive opens of the same conversation within a short window. That’s roughly a 6x reduction from this technique alone, before even accounting for the piggybacking and server-side batching layered on top — a concrete illustration of why cursor-based tracking isn’t a minor optimization but the single biggest lever in the whole design.
7.5 Choosing Debounce and Batch Windows
The actual numeric values for debounce and batch windows are a tuning problem, not a fixed constant, and should be informed by real usage data rather than picked arbitrarily. A useful mental model: the debounce window should be long enough to capture the vast majority of a natural “reading burst” (empirically, most users finish scanning a handful of new messages within one to two seconds of opening a chat) but short enough that the sender doesn’t perceive a lag when watching for a reply to a message they just sent, which is the moment sender attentiveness — and therefore sensitivity to receipt latency — is highest. Many production systems land in the 300ms–1.5s range for read-cursor debouncing and a similarly short window (200–500ms) for server-side batching of outbound status pushes to the sender, with these values typically exposed as tunable configuration rather than hardcoded, so they can be adjusted based on observed metrics (see Section 10) without a code deployment.
“How would you decide on the actual debounce window value rather than just picking a round number?” A strong answer: I’d want to look at real distribution data on how quickly users read consecutive unread messages within a session — essentially, the inter-message read-gap distribution — and pick a window that captures a large majority (say, the 90th or 95th percentile) of natural reading bursts without being so long that it starts to feel laggy for the minority of cases where a sender is actively watching for confirmation. I’d also make the value a runtime-configurable parameter rather than a hardcoded constant, so it can be A/B tested and tuned based on production telemetry rather than guessed once at launch and never revisited.
High Availability & Reliability — Resilience
Status tracking must never block message delivery
The core message send/receive path should succeed independently of whether the status aggregation subsystem is healthy — a “delivered” checkmark that’s slow to appear is a minor UX blemish, while a message that fails to send because a status service is down is a critical failure. This is enforced by the same kind of architectural decoupling used in the message search design: status updates flow through their own asynchronous path, never a synchronous dependency of message send.
At-least-once, idempotent acknowledgement delivery
Because the underlying transport (mobile networks, push notifications) is inherently unreliable, acknowledgements should be designed to be safely retried and safely duplicated — the cursor-based model described earlier makes this straightforward, since re-applying the same “read up to N” update twice is a no-op.
Reconnection resync
When a client reconnects after being offline, it should resync its outstanding delivered/read cursors as part of the reconnection handshake, ensuring no acknowledgement is permanently lost due to a dropped connection during the exact moment it would have been sent.
Graceful degradation
If the Status Aggregation Service is degraded or down, the platform should fail toward “no status shown” or “last known status” rather than showing incorrect (e.g., falsely regressed) status — silence is a safer failure mode than misinformation here.
Replication of the status store
The per-message, per-recipient status store should be replicated across availability zones for durability, though — unlike the message content store — some short window of potential status-update loss during a rare failure event is a more acceptable trade-off given the data’s lower stakes, allowing for a lighter-weight replication and durability posture than the message store itself.
Security & Privacy — Protection
9.1 Read Receipts as a Privacy-Sensitive Feature
Whether someone has seen a message — and when — is meaningfully sensitive personal information; it can reveal availability, attentiveness, or even relationship dynamics (the classic “they read it and didn’t reply” anxiety). Because of this, read receipts are almost universally implemented as an opt-in or reciprocally-toggleable feature rather than an always-on default: a common and well-regarded pattern (used by WhatsApp, among others) is that if a user disables sending read receipts, they also lose the ability to see others’ read receipts — a reciprocal design that discourages one-sided surveillance (watching others’ read status while hiding your own) and keeps the feature socially fair.
9.2 Access Control on Status Data
Delivery and read status for a message should only ever be visible to the sender of that message (and, for group aggregate counts, potentially visible more broadly depending on product design) — never to unrelated third parties. This needs the same conversation-membership-based access control discussed for message content itself: the Status Aggregation Service should verify that any client requesting status information is actually the sender (or an authorized participant) of that specific message before returning data.
9.3 Metadata Minimization
Precise read timestamps are more revealing than coarse ones. Some platforms deliberately round or bucket read timestamps (e.g., to the nearest minute) rather than exposing millisecond precision, reducing the granularity of inferences a sender could draw (such as detecting exactly how long a recipient took to open a specific message during a specific narrow window). This is a privacy-by-design trade-off worth calling out — perfect accuracy is not always the goal even where it would be technically easy to achieve.
9.4 Encryption Considerations
For end-to-end encrypted platforms, delivery and read receipts themselves are typically also encrypted or otherwise protected in transit, since even metadata like “message X was read at time Y” can be sensitive; the acknowledgement payloads should not leak message content and should be authenticated (so a malicious actor cannot forge a false “read” receipt on someone else’s behalf, which would be a meaningful integrity violation given how socially significant read status can be).
A frequent mistake is implementing the read-receipt opt-out as purely a client-side UI toggle that just hides the indicator locally, while the server continues to compute and transmit full read status regardless. This does not actually protect privacy — a modified or third-party client could still extract the “hidden” data — and fails to meet the actual intent of the feature. The opt-out needs to be enforced server-side, at the point where read events are generated or transmitted, not just suppressed in rendering.
9.5 Regulatory Considerations
In jurisdictions with strict data protection regimes (e.g., GDPR in the EU), read status is generally considered personal data about the recipient (it reveals behavior — when and whether they engaged with content), which means it’s subject to the same lawful-basis, consent, and retention-limitation requirements as other personal data categories. Practically, this reinforces the case for treating the read-receipts opt-out as a genuine, server-enforced consent mechanism rather than a cosmetic setting, and for not retaining fine-grained historical read-timestamp data longer than the platform has an actual product justification for — a “read at 3:42:17pm on March 3rd two years ago” record that no feature actually uses is retained regulatory risk with no offsetting product value.
Monitoring, Logging & Metrics — Visibility
| Metric | Why it matters |
|---|---|
| Status event volume vs. message volume ratio | Directly measures chatter efficiency; a ratio climbing well above the expected batched baseline signals a batching/debouncing regression. |
| Delivered-status latency (message send to delivered shown) | Core sender-facing UX metric for the fast-path acknowledgement. |
| Read-status latency (message read on device to shown to sender) | Should track the intended debounce window; a metric that drifts significantly above the configured window signals backend lag, not client-side debouncing. |
| Duplicate/no-op status event rate | Tracks how often idempotent re-application occurs — useful for understanding reconnection and retry behavior, though some baseline rate is expected and healthy. |
| Status-out-of-order correction rate | How often the monotonicity-enforcement logic has to correct an out-of-order event; a spike can indicate a network partition or a bug in event sequencing. |
| Push notification wake-to-ack latency | For offline recipients, measures the full round trip from push delivery to the resulting device acknowledgement, which is usually the slowest path in the system. |
| Group aggregate computation latency | For large groups, tracks how quickly the “read by X of Y” summary reflects underlying member events, especially important as group size grows. |
Because status data is high-volume and comparatively low-stakes per individual event, it’s a good candidate for sampled logging and metrics rather than exhaustive per-event logging, which itself would reintroduce a meaningful amount of the “chatter” the design is trying to minimize — even the observability layer needs to respect the same efficiency principle as the feature it’s observing.
“Suppose you notice the ‘duplicate/no-op status event rate’ metric climbing steadily over a few weeks with no obvious release correlated to it. How would you investigate?” A strong answer: I’d first segment the metric by client platform and app version, since a steadily climbing trend uncorrelated with a single release often points to a slow rollout of a problematic client version, an aging client version with a retry-logic bug that’s becoming more prevalent as it ages out of use, or a specific network condition (e.g., a region with degrading connectivity) becoming more common. I’d also check whether it correlates with increases in reconnection frequency, since a rising duplicate rate is often a downstream symptom of clients reconnecting and resyncing more often than expected — in which case the real root cause is connection stability, not the acknowledgement logic itself, and the duplicate-rate metric is doing its job as an early warning signal for a problem elsewhere in the stack.
Deployment & Cloud — Rollout
The Status Aggregation Service is a natural candidate for a horizontally-scaled, stateless (or lightly-stateful, with externalized state in a fast store like Redis or a purpose-built key-value store) service deployed behind the same connection layer that handles live WebSocket/MQTT connections, since status updates are so tightly coupled to connection lifecycle events (connect, disconnect, reconnect). It benefits from being deployed close to the connection-handling infrastructure (same region/AZ where possible) to minimize the latency of the fast-path delivered acknowledgement, while the batching/debouncing logic itself is comparatively latency-insensitive and can tolerate being one hop further away if needed for operational simplicity.
Push notification delivery (APNs/FCM) is inherently an external dependency outside the platform’s direct control, and deployment needs to account for provider-side rate limits and quotas — batching push-triggered wake-ups where possible, and building in backoff/retry logic for provider-side throttling, is a deployment-level concern as much as an application-level one.
Databases, Caching & Load Balancing — Storage Layer
12.0 Choosing Between Push and Pull for Status Retrieval
There are two general models for getting status updates to the sender: server-initiated push (the server proactively notifies the sender’s client the moment a batched status update is ready) and client-initiated pull (the sender’s client periodically polls for status on its recently-sent messages). Push is strongly preferred for the primary experience, since it enables the low-latency “delivered” and reasonably-fresh “read” indicators users expect without wasteful polling overhead, but a pull-based reconciliation path is still valuable as a fallback — for instance, when a client reconnects after being offline for an extended period, a single explicit pull request for “status of all my messages sent since I went offline” is more efficient than replaying a long backlog of individual push events that occurred while disconnected.
12.1 Status Store Design
The status store’s natural access pattern — frequent small updates keyed by (message_id, recipient_id) or, more efficiently, by (conversation_id, recipient_id) for the cursor-based model — favors a wide-column or key-value store optimized for high write throughput and simple point lookups (Cassandra/ScyllaDB, DynamoDB, or a Redis-backed store for the hottest, most recent data) over a relational database, which would struggle with the sheer update volume even after batching optimizations are applied.
12.2 Caching
Because most status queries are “what’s the current status of this sender’s recently-sent messages,” a cache of recent per-conversation status state (keyed by conversation, holding the current read cursor per member) serves the overwhelming majority of status-related reads without touching the durable store, and this cache can itself be the primary read path for the fast-moving cursor model, with the durable store acting more as a periodically-flushed backing store and source of truth for reconnection resync than as the primary hot-path read target.
12.3 Load Balancing
Status update traffic should be load-balanced across Status Aggregation Service instances using a consistent-hashing scheme keyed by conversation_id (so all status traffic for a given conversation lands on the same instance, enabling efficient in-memory batching/debouncing state without cross-instance coordination), similar in spirit to the user-based sharding strategy used for search, but keyed differently to match this subsystem’s own dominant access pattern.
APIs & Microservices — Interfaces
| Service | Primary API / Trigger | Scaling driver |
|---|---|---|
| Delivery Service | Internal: triggered by message fan-out from the Messaging Service | Message send volume × average recipient count |
| Status Aggregation Service | POST /status/ack (cursor update), internal push to sender | Status event volume post-batching; conversation activity concurrency |
| Push Notification Bridge | Internal: interfaces with APNs/FCM for offline delivery | Volume of offline-recipient message deliveries |
| Privacy Settings Service | GET/PUT /settings/read-receipts | Low read/write volume, but consulted on every status event emission — latency-sensitive as a dependency, not throughput-heavy itself |
Keeping the Delivery Service and Status Aggregation Service as distinct components (rather than folding acknowledgement handling directly into the Messaging Service) mirrors the separation-of-concerns principle used throughout this design: message delivery and status tracking have different reliability requirements, different scaling drivers, and different failure-isolation needs, and coupling them tightly would make it harder to evolve or scale either independently.
13.1 API Design Details
The POST /status/ack endpoint (or its equivalent persistent-connection message frame, for clients that maintain a live WebSocket rather than issuing discrete HTTP calls) should accept a compact payload built around the cursor model: a conversation identifier, the highest message sequence number confirmed as delivered or read, a status type (delivered vs. read), and a client-side timestamp. Keeping this payload minimal matters directly for the chatter-reduction goal — every additional field is additional bytes multiplied across billions of daily events. The response, correspondingly, should be minimal or entirely absent (fire-and-forget over a reliable transport, relying on transport-level acknowledgement rather than an application-level response payload) wherever the underlying connection already guarantees delivery, avoiding a redundant round trip purely to confirm receipt of an acknowledgement.
Design Patterns & Anti-patterns — Reusable Wisdom
14.1 Patterns Used
Cursor-based state tracking
Replacing per-item acknowledgement with a single monotonically-advancing position marker — the single most impactful pattern in this design for chatter reduction.
Debounce / batch-and-flush
Collapsing bursts of rapid events into a single delayed emission, trading a small amount of latency for a large reduction in event volume.
Piggybacking
Attaching low-priority data to already-scheduled network traffic rather than opening new, dedicated round-trips.
Graceful degradation to coarse aggregates
For very large fan-out scenarios, deliberately reducing precision (exact count → approximate count) as a first-class design response to scale, rather than trying to preserve exact precision at any infrastructure cost.
Idempotent, at-least-once event processing
Designing every status update to be safely re-appliable, which allows the transport layer to retry freely without needing complex distributed deduplication.
14.2 Anti-patterns to Avoid
- Per-message, per-event synchronous acknowledgement: sending an individual network round trip for every single delivered/read event without any batching — the textbook chatter anti-pattern this entire design exists to avoid.
- Client-side-only privacy enforcement: hiding read receipts in the UI without suppressing the underlying data server-side, as covered in the security section.
- Treating status updates as equally reliable/durable as message content: over-engineering the status store with the same strict durability and replication guarantees as the message store wastes resources on data that is, by nature, more tolerant of rare loss or brief staleness.
- Ignoring multi-device read-state divergence: failing to synchronize a user’s own read cursor across their own multiple devices leads to confusing, inconsistent unread badges and can cause a message to appear unread to the sender even after the recipient genuinely read it on a different device.
- Unbounded per-member fan-out in large groups: preserving fully granular, real-time per-member status tracking and pushing at group sizes where it no longer provides proportional user value, at significant and avoidable infrastructure cost.
Best Practices & Common Mistakes — Doing It Right
Best practices
- Default to a “read up to” cursor model rather than per-message acknowledgement wherever the product doesn’t specifically require granular per-message detail.
- Treat delivered and read acknowledgements as different tiers of urgency, with different batching policies tuned to their different value/volume trade-offs.
- Enforce privacy opt-outs at the point of event emission on the server, never only in client-side rendering.
- Design every acknowledgement event to be idempotent from the outset, so retries and reconnection resyncs never require special-case deduplication logic.
- Cap and degrade gracefully for very large fan-out scenarios (huge groups/channels) rather than assuming granular real-time tracking scales linearly forever.
- Keep status tracking fully decoupled from the message send critical path, so a status subsystem outage never blocks or slows down actual message delivery.
Common mistakes
- Under-tuning the debounce window — too short and it barely reduces chatter; too long and read status starts to feel noticeably laggy to users, undermining the feature’s core value.
- Forgetting to resync outstanding acknowledgements on reconnection, silently losing status updates that occurred while a client was offline.
- Building the status store with the same schema and access patterns as the message store, rather than designing specifically for its much higher update frequency and different durability needs.
- Not load-testing group aggregate computation at realistic large-group sizes, only discovering the O(members) cost blowup once a popular channel actually hits production scale.
- Overlooking that read receipts are a reciprocal, socially sensitive feature and shipping it as always-on without a considered privacy/consent model.
Real-World Industry Examples — In Practice
WhatsApp’s two-checkmark system (one gray check for sent, two gray checks for delivered, two blue checks for read) is one of the most widely recognized implementations of this pattern, and its reciprocal privacy design — disabling read receipts also disables seeing others’ — is frequently cited as a thoughtful, socially-aware privacy default that other platforms have since emulated.
iMessage
Apple’s iMessage shows “Delivered” and, if enabled, “Read” as text beneath a message, with read receipts as an explicit per-conversation opt-in toggle rather than a single global setting — reflecting a more granular privacy control philosophy where a user might want read receipts on for close contacts but off more broadly.
Slack
Slack deliberately does not implement traditional message-level read receipts in the same visible way as consumer chat apps, instead relying on a channel-level “last read” cursor per user, primarily used internally to compute unread counts and highlight new messages, rather than surfaced as a social read-confirmation signal to other users — a good illustration of how a workplace collaboration tool’s product philosophy (reducing social pressure and “have they seen my message yet” anxiety) directly shapes a fundamentally different design choice from consumer messaging.
Telegram
Telegram shows read receipts by default in most private chats with less user-configurable control than WhatsApp, but in large groups and channels — mirroring the scalability argument made earlier in this tutorial — Telegram displays only an aggregate “seen by” count rather than attempting a fully granular per-member real-time breakdown once membership grows large, a direct real-world instance of degrading to coarse aggregates at scale.
Signal
Signal, with its strong privacy-first design philosophy, makes read receipts an explicit, off-by-default, globally reciprocal opt-in setting, and — consistent with its end-to-end encrypted architecture — delivery and read receipts themselves are sent as encrypted messages between devices rather than server-visible metadata, extending the platform’s privacy guarantees to the acknowledgement layer itself, not just message content.
Discord
Discord notably does not implement traditional read receipts at all in the way consumer chat apps do — it tracks per-channel “last read” position purely to drive each user’s own unread-message badges and mention notifications, with no signal exposed to other users about whether or when a specific message was read. This is another clear illustration of the Slack-style philosophy: in a platform built around large, persistent communities rather than primarily 1:1 intimate conversation, the social pressure implied by visible read receipts is generally considered a poor fit for the product, even though the underlying cursor-tracking mechanism is architecturally very similar to what platforms with visible read receipts use internally.
The contrast between Slack (no traditional read receipts, cursor-based unread tracking only) and WhatsApp (full granular read receipts by default, reciprocal opt-out) is a clean illustration that this isn’t purely a technical design problem — the “right” level of accuracy and visibility is fundamentally a product and social-context decision, and the underlying batching/cursor architecture described in this tutorial can support either philosophy; it’s the exposed product surface, not the backend architecture, that differs most between these platforms.
Frequently Asked Questions — Quick Answers
Why not just have the server assume a message is “read” once it’s been delivered, and skip a separate read-tracking mechanism entirely?
Delivery only confirms the message reached the device’s storage — it says nothing about whether a human actually looked at it, which is a materially different and more socially significant signal that many product experiences (and many users) specifically want. Collapsing the two would either under-report (never show “read” and lose real functionality) or over-report (falsely claim something was read that wasn’t), both of which undermine trust in the indicator. The two need genuinely separate tracking because they represent genuinely separate real-world events.
How do you prevent a malicious client from sending a false “delivered” or “read” acknowledgement to make a sender believe a message was seen when it wasn’t?
Acknowledgements should be authenticated and tied to the recipient’s verified session/device identity — the server should only accept a delivered/read event from the specific device that a message was actually routed to, not accept an arbitrary claim from any client. For end-to-end encrypted platforms, cryptographically signing acknowledgement events (so they can be verified as genuinely originating from the recipient’s device, not forged or replayed by a third party) adds a further layer of integrity, since read status can carry real social significance and forging it would be a meaningful trust violation.
What happens to delivery/read status if a recipient uninstalls the app and reinstalls it later, or gets a new device?
Since the message itself may already have been delivered and possibly deleted from server-side transient delivery queues by the time this happens (many platforms don’t retain messages indefinitely server-side, especially E2E-encrypted ones), a fresh reinstall commonly cannot retroactively acknowledge older messages the same way. Most platforms treat this edge case as acceptable: the message is typically still shown as “delivered” (it genuinely was, to the old install), and read status either remains as last known or is left unresolved rather than the system attempting to fabricate a retroactively accurate read state it can no longer verify.
How would you test that the batching and debouncing logic is actually working correctly and not silently dropping status updates?
End-to-end tests that simulate realistic burst patterns (e.g., programmatically “reading” 50 messages within a 100ms window and asserting exactly one batched cursor update is emitted, not 50, and not zero) combined with production metrics tracking the status-event-to-message-volume ratio described in the monitoring section — a ratio that drifts upward over time in production is a strong signal that batching has regressed somewhere, even if unit tests still pass, since real-world traffic patterns are harder to fully replicate in a test suite than to observe directly in aggregate metrics.
How do you handle the case where a sender is offline when a delivered or read status update occurs — does the sender simply never see it?
No — status updates need the same reliable, eventually-delivered treatment as messages themselves from the sender’s perspective. The status store durably persists the latest known state per message regardless of whether the sender is currently connected, and when the sender’s client reconnects, it fetches (or is pushed, via the same reconnection-resync mechanism used for missed messages) any status changes that occurred while it was offline. This is why decoupling the status store from ephemeral, connection-only delivery is important — status has to survive independently of any particular connection session on either side, sender or recipient.
Should delivered/read status be part of the same real-time channel (e.g., the same WebSocket) as message content, or a logically separate channel?
They can share the same underlying transport connection for efficiency (that’s exactly what piggybacking relies on), but they should be logically separate message types/topics within that transport, each with its own delivery guarantees and processing path on the server side. This separation matters because it lets the two be reasoned about, monitored, and evolved independently — for instance, changing the batching policy for status updates shouldn’t require touching message-content delivery code at all if they’re cleanly separated at the protocol level, even while physically sharing a connection.
How would this design change for a platform that wants “typing indicators” as well — is that the same mechanism as delivered/read status?
Typing indicators are a related but distinct signal with an even higher tolerance for loss and a much shorter relevance window — a typing indicator that’s five seconds stale is essentially useless, whereas a read receipt that’s five seconds stale is barely noticeable. In practice, typing indicators are usually treated as fully ephemeral, fire-and-forget, best-effort events that are explicitly not persisted to a durable store or retried on failure — sending one and having it occasionally get dropped is an acceptable trade-off given how transient the underlying state is, which is a meaningfully looser reliability bar than delivered/read status intentionally targets throughout this design.
Summary & Key Takeaways — Wrap-Up
Key takeaways
- Delivery confirmation is fundamentally a distributed state-tracking problem layered on top of unreliable networks and multiple devices per user — modeling it as an explicit, monotonic state machine (Sent → Delivered → Read) prevents subtle “status regression” bugs.
- The single highest-leverage technique for reducing network chatter is a “read up to” cursor instead of per-message acknowledgements, collapsing many events into one.
- Debouncing and batching trade a small, generally imperceptible amount of latency for a large reduction in total event volume, and should be tuned differently for delivered (fast, near-immediate) versus read (aggressively batched) status given their different value/volume profiles.
- Piggybacking acknowledgements onto existing connection traffic further reduces marginal network cost toward zero in the common case.
- At very large scale (huge groups/channels), the right response is to degrade precision deliberately — coarse approximate counts instead of exact real-time per-member tracking — rather than trying to preserve full granularity at disproportionate infrastructure cost.
- Read receipts are a privacy-sensitive, socially significant feature that needs server-side-enforced consent and opt-out, not just client-side UI toggles, and different platforms make meaningfully different product choices here even atop very similar underlying architectures.
- Status tracking must remain fully decoupled from the core message send path, so that its own failures or slowdowns never compromise the platform’s most critical guarantee: that messages actually get delivered.
The two-checkmark indicator looks like a design detail. It’s a distributed system in disguise — and every millisecond of user-perceived accuracy is bought or saved in the batching windows, the cursor semantics, and the boring, load-bearing decoupling between what the sender sees and how the message actually got there.