SD System Design In Depth
0%
design system-designsocialmessaging

Design: a newly-unread indicator

Module 4.4: Real-Time Messaging & Feed Architectures Updated Sep 18, 2026
📄 Cheat Sheet
Design Whatsapp: System Design Interview w/ a Ex-Meta Senior Manager
Hello Interview Design Whatsapp: System Design Interview w/ a Ex-Meta Senior Manager Watch on YouTube ↗

Kept: no dedicated video exists for unread badges, and this is the clearest treatment of per-recipient inboxes, message ordering, delivery acknowledgements and multi-device cursors that the badge design builds on.

0:00 / --:--

Design: a newly-unread inbox indicator

A newly-unread badge tells someone how much new activity has arrived since they last opened an overview. It can clear when they open that screen even though they have not read every conversation. That makes it a separate feature from message read receipts.

We'll design a badge that counts distinct senders, preserve arrivals that race with opening the inbox, and keep reads correct while a background worker is behind. The example is our own small inbox service, not a claim about Slack's or another messenger's internals.

Decide what the number means

Bo sends Ada two messages and Cy sends one. Our badge shows 2, because two sender accounts contributed new activity. It does not show three messages, two humans, or a count of unread conversations.

StateWhat it means
Newly-unread badgeDistinct eligible senders after Ada's last inbox acknowledgement
Thread unreadA conversation contains messages beyond its own read position
Message read receiptThe client reported a particular message or position as read
Opening the overview advances only the first state. The chat design deals with delivery and thread-read reports separately. None of these reports proves that a person understood the text.

Repeated delivery of one send must not create another message. But two intentional sends with identical text are still two messages. Give each send an operation ID; use sender identity only when collapsing messages into the badge count.

For this design, only the recipient may view or acknowledge the inbox. Senders and recipients must be active accounts, and inactive senders disappear from badge reads. Blocking, group conversations and message deletion need additional rules; they are outside the executable example.

Clear what the screen observed

Deleting the whole badge set when Ada opens the inbox looks simple. It fails if a new message arrives after the server prepared her response but before the acknowledgement reaches the server. The deletion clears activity she never had a chance to see.

Give each accepted message a position within its recipient's inbox. Ada's first three messages occupy positions 1, 2 and 3. The response records that it observed through 3. Dee's later message receives position 4.

sequenceDiagram accTitle: Acknowledge the observed inbox accDescr: Ada receives an inbox snapshot through position 3. Dee's message commits at position 4 before Ada acknowledges the older snapshot. Position 4 remains newly unread. participant A as Ada participant S as Inbox service A->>S: Open inbox S-->>A: Snapshot through 3 Note over S: Dee's message commits at 4 A->>S: Acknowledge snapshot 3 S-->>A: One new sender remains

Store an acknowledged-through position A. A sender counts when their latest eligible message position is strictly greater than A. After acknowledging 3, Bo and Cy stop contributing, while Dee at 4 remains.

Positions describe acceptance order for one recipient. They are not wall-clock timestamps or a global order across all users. Equal timestamps cannot tell us which of two arrivals belonged to an earlier inbox response.

The server stores an issued snapshot containing its recipient and observed boundary. Ada acknowledges the snapshot ID, not an arbitrary position supplied by her client. The server checks ownership and updates A to the greater of its current value and the snapshot boundary.

That maximum handles multiple devices. If device B has already acknowledged through 4, device A's delayed acknowledgement through 3 leaves A at 4. A retry with the same operation ID returns its original receipt; that historical reply must not make the client lower a newer local boundary.

The product decides when to send the acknowledgement: for example, after the overview loads successfully. Issuing a snapshot only proves which response the server prepared, not that Ada saw it. An acknowledgement should not be sent just because navigation began and the request might still fail.

Store the source and the prepared count separately

Accepted messages are the source. A projection keeps each sender's greatest processed position so a badge read need not rescan the entire message history. Let H be the accepted head and P the position through which the projection is complete.

Our local service puts these records in one SQLite database:

Record and keyRelevant fields
Message: recipient, positionSender and text
Inbox head: recipientH, A and P
Sender summary: recipient, senderGreatest processed position
Issued snapshot: snapshot IDRecipient and observed-through position
Operation receipt: actor, operation IDOriginal input and accepted result
Separate thread-read rows exist to demonstrate that inbox acknowledgements leave them unchanged. A real thread-reading API is outside this service.
flowchart TD accTitle: The badge combines a prefix and a tail accDescr: A badge read merges sender maxima processed through P with source messages after P, then counts active senders whose latest position is above acknowledgement A. All records are read from one SQLite snapshot. P[Sender maxima through P] --> M[Merge latest positions] T[Messages after P] --> M M --> C[Count active senders
above A]

A read uses one database snapshot for the heads, projection, tail and account eligibility. Otherwise, it could combine a new P with an old projection and miss messages. The count is exact for that snapshot; an arrival after it begins can appear on the next read.

This is a deliberate cost choice. With P=0 and H=4, the read examines four tail messages. Once the worker reaches P=4, the tail is empty, though this implementation still reads the sender summaries and checks account eligibility.

Follow a send, a read and an acknowledgement

The HTTP handler binds its demonstration credential to an account. Source methods then enforce ownership. The routes keep the three operations distinct:

RouteInput or result
POST /messagesSend an operation ID, recipient and text; receive an accepted position
GET /inbox/AdaReceive eligible messages, snapshot ID and observed-through position
GET /badge/AdaReceive the count and diagnostic H, A, P and tail-row fields
POST /inbox/Ada/ackSend an operation ID and issued snapshot ID; receive acknowledged-through
Bo's send transaction checks his operation receipt, validates the recipient, advances Ada's head, inserts the message and saves the result. Commit precedes the 201 response. The same operation and input return 200 with the original position; changed input under that ID returns 409.

Opening Ada's inbox reads its head and eligible message history, then stores the snapshot in the same transaction. The example returns the entire retained history. Pagination would require a product decision: does opening page one acknowledge the overview, or only messages actually fetched? Do not extend the current token to pages without answering that.

Acknowledgement verifies the path's account and the snapshot's owner independently. A predictable snapshot ID is not permission to use it. A guessed snapshot belonging to Ada is refused when Bo submits it through his own route.

SQLite serializes the example's write transactions. Dee's arrival may commit before or after Ada acknowledges the older snapshot; either way, the final state is H=4 and A=3. The acknowledgement never replaces its issued boundary with the current head.

Keep projection lag from changing the answer

The background worker reads messages after P in order. It updates each sender's maximum and advances P in the same transaction. An interrupted transaction leaves both unchanged, so retrying starts from the last committed position.

A badge read merges those processed maxima with every message in the unprocessed tail, taking the greater position per sender. It then counts active senders above A. Delayed work from Bo cannot undo Ada's acknowledgement or turn an older message into new activity.

This retains the original goal of preparing cheap reads, while making the fallback cost visible. Measure tail length, worker progress and badge-read latency. If a tail becomes too large, catch the worker up, bound recovery with an explicit failure, or choose a documented stale-count experience. Returning an old projection as an exact current count is not the same contract.

A live notification can tell an online client to refetch. A disconnected client reconciles on its next badge read, as in the realtime recovery lesson. This particular example sends no WebSocket notifications; it tests the source and reconciliation path.

Run the arrival race

Save the standard-library inbox service and shared helpers together. They use a private temporary SQLite file and an actual loopback HTTP server with fake account-bound credentials.

Bo sends twice and Cy once. Device A fetches through 3, then two threads race Dee's fourth message against acknowledgement of that snapshot. A later device acknowledges through 4 before the first device submits its older snapshot again.

python3 m22-inbox.py
offline arrivals: events=3 distinct senders=2
duplicate message: 200
changed message: 409
device A observes through: 3
arrival races ACK3: {"accepted_through":4,"acknowledged_through":3,"count":1,"projected_through":0,"senders":["Dee"],"tail_rows":4}
clear-all control: would drop new sender Dee; boundary ACK retains her
projection interruption/reopen: projected=0
delayed projection after ACK: {"accepted_through":4,"acknowledged_through":3,"count":1,"projected_through":4,"senders":["Dee"],"tail_rows":0}
device B ACK4 then old device ACK3: 4
wrong-user acknowledgement: 403
wrong snapshot owner: 403
malformed framing: [["empty transfer encoding",400],["ambiguous length",400],["huge numeric length",400],["unpaired surrogate",400],["short body",400]]
thread read positions: [0, 0, 0]
retained events=4 serialized message bytes=245
unavailable authority: 503
HTTP requests: application=15 malformed=5

The badge finds Dee both before projection and after it. Only tail_rows changes from four to zero. The thread-read positions stay at zero, confirming that the overview acknowledgement did not mark conversations read.

The interruption is an exception before commit followed by reopening the database, not an operating-system crash. The replay also checks retry identity, ownership and malformed HTTP requests. Its 245-byte count covers only compact message JSON, not database size or network overhead.

Those protocol probes exercise this small server's rules: one bounded Content-Length, no Transfer-Encoding, complete bodies and valid JSON Unicode. They are not a production authentication or load test. An unavailable source returns 503, so the client should retain a clearly stale display or show unavailability rather than invent zero.

Where a Redis sorted set fits

A sorted set is useful for the prepared sender summary: the recipient identifies the key, the sender ID is the unique member, and the latest accepted position is its score. Updating Bo changes his score without creating another member. Resolve display names separately so a rename does not change identity.

For small exact integer positions, the core operations look like this:

ZADD newly_unread:Ada GT 2 Bo
ZADD newly_unread:Ada GT 3 Cy
ZADD newly_unread:Ada GT 4 Dee
ZCOUNT newly_unread:Ada (3 +inf
GT prevents an older delivery from lowering an existing score. The exclusive bound (3 counts only positions above acknowledgement 3. The result is one sender. This sketch assumes A=3 is already known; it does not implement cross-store acknowledgement or projection recovery. ZCARD is sufficient only if every retained member is newly unread. Once old entries remain, use the acknowledgement boundary. Deleting scores through A can reclaim space, but a late event can reinsert an old sender; counting above A still excludes that activity.

The Redis control starts a private process with TCP disabled and demonstrates the unsafe alternatives:

python3 m22-redis-badge.py
Redis server v=8.4.0
snapshot through3: distinct senders=2
arrival4 then clear-all: count=0
remove through3: senders=Dee
delayed older update: ZCARD=2; ZCOUNT above ACK3=1
equal timestamp100: remove-through100 removes both senders

Redis scores are floating-point numbers. Integers through 2^53 are exact, but arbitrary 64-bit source positions are not. Choose a representation that preserves the actual position range before using scores as acknowledgement boundaries.

A separate Redis deployment also adds a consistency boundary. Preserve message acceptance in the source, use replayable work to maintain the projection, and keep acknowledgement authoritative during cache loss. A cache miss means “state unavailable or not yet built,” not necessarily “no new messages.”

Retain enough to recover

The local database retains all messages, snapshots and operation receipts for its temporary lifetime. Production retention cannot discard them under one guessed expiry: messages support reconstruction, snapshots authorize acknowledgements, and receipts suppress repeated operations.

A clean projection rebuild resets sender maxima and P together, then replays retained messages. The badge path checks that its unprocessed tail reaches H without gaps; missing source positions produce a conflict instead of an incomplete count.

If history must be trimmed, retain a sufficient checkpoint and define a floor below which replay is unavailable. Account erasure and message deletion must also update the eligibility and summary rules. A surviving sender maximum alone does not prove that its supporting message still exists.

The essential boundary is simple: clearing the overview covers the inbox the server returned. New work beyond that boundary remains visible, regardless of device timing or background-worker progress.


Implementation notes for a newly-unread badge

This design defines the badge precisely as distinct eligible senders after the last acknowledged inbox snapshot. It is not a count of unread messages or conversations, and opening the overview does not advance thread read receipts.

The unsafe clear-all approach

SET badge 0 loses an arrival that commits after the server prepared the inbox response but before the clear. Counting rows with read = false also computes a different product metric from distinct senders after an overview boundary.

Prepared sender maxima

Keep the accepted message stream authoritative. A projection may store each sender's greatest recipient-local position, for example as a Redis sorted-set member scored by position. Count members strictly above the acknowledged-through boundary. If projection progress lags, merge the projected prefix with source messages in the unprocessed tail rather than returning a stale cache value as exact.

Handling Consistency and High Water Marks

Counters can easily drift out of sync due to network failures or race conditions. A more robust approach uses a "High Water Mark" or cursor. The server issues an inbox snapshot containing the recipient and the greatest accepted position included in that response. An acknowledgement names that server-issued snapshot, and the stored boundary advances by max(current, snapshot_position) so a delayed device cannot move it backward.

An arrival beyond the acknowledged snapshot remains newly unread even if it races with the acknowledgement. Cache loss triggers reconstruction from retained source messages or a checkpoint; it must not turn unknown state into zero.

Worked example with positions

Recipient-local positions are assigned when messages are accepted. Current sender maxima in the projection:

SenderGreatest position
alice1040
bob1045
  1. The client opens the overview. The server builds the response, which includes everything up to position 1045, and returns snapshot token 1045.
  2. While the response is in flight, carol's message commits at 1047 and alice sends again at 1049.
  3. The client acknowledges snapshot 1045. The boundary becomes max(previous, 1045) = 1045.
  4. Badge = senders with maximum above 1045 = carol (1047) and alice (1049) = 2. The naive SET 0 would have shown 0 and hidden both.
  5. An old tablet later acknowledges snapshot 1040. The boundary stays at max(1045, 1040) = 1045.

Redis projection

ZADD newly:{user} GT 1049 alice keeps only the greatest position per sender (the GT flag needs Redis 6.2 or later). The badge is ZCOUNT newly:{user} (1045 +inf, where the parenthesis makes the bound exclusive. Periodically ZREMRANGEBYSCORE newly:{user} -inf 1045 trims senders at or below the boundary so the set stays small.

Design options

ApproachCorrect under races?CostNotes
Counter incremented on arrival, SET 0 on openNoLowestLoses arrivals that race with the clear; drifts on retries
COUNT unread rows at read timeYes, but measures messages not sendersHigh on large inboxesDifferent product metric
Sender maxima plus server-issued boundaryYesOne sorted set per userNeeds projection lag handling
Recompute from message log on every openYesHighestUseful as the rebuild path after cache loss

Failure modes

  • Projection lag: if the projector has processed up to 1046 but the log is at 1049, merge the projection with a scan of positions 1047 to 1049 rather than returning a stale count as exact.
  • Cache loss: rebuild from retained messages or a checkpoint; never initialize an unknown badge to 0.
  • Blocked or muted senders: eligibility is evaluated at read time, so a later block removes the sender from the count without rewriting history.
Check yourself Question 1 of 3
Why SET 0 is wrong recall
From: Implementation notes for a newly-unread badge

A badge implementation increments a counter on each arrival and runs SET badge 0 when the user opens the inbox overview. What correctness bug does the lesson identify?

Key Takeaways

Keep newly-unread semantics distinct from message counts, conversation unread state, and read receipts.
Use recipient-local positions and server-issued snapshot acknowledgements so racing arrivals are not cleared.
A prepared sender projection may accelerate reads, but retained source messages and projection progress make lag recoverable.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue