"Design read receipts" sounds like the warm-up question in a system design interview. Three little ticks, one status column, done in five minutes. Then the interviewer asks what happens when the phone was in a tunnel, and the five minutes turn into forty.
I recently built a WhatsApp-style receipts feature in .NET 10, and these are the six questions I wish someone had asked me before I wrote the first line. Each one has the short answer I'd give now and a skeleton of the code. Where the real implementation is longer, the snippet says so.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, tested project behind this post (
whatsapp-delivery-receipts-dotnet, Clean Architecture, 185 passing tests, plus a 95-page PDF) is inside. Join Tech Skill Builder
The spec, in WhatsApp's own words
WhatsApp's Help Center gives the whole contract in a few lines. One check means the message was sent. Two grey checks mean it was "delivered to the recipient's phone or any of their linked devices". Two blue checks mean it was read. In a group, the second check only appears when everyone has received it, and blue only when everyone has read it. Editing a message resets read receipts. Voice notes also have a "played" state.
Keep those sentences handy. Every question below is one of them under pressure.
Q1. A "Read" arrives before the "Delivered". What does the sender see?
Short answer: blue ticks, and they stay blue.
Priya opens your message on her phone right as it reconnects. Her app sends Delivered and Read close together, the network reorders them, and Read lands first. If your handler does status = incoming, the late Delivered turns the ticks grey again. The user sees a message become "unread".
The fix is to stop setting the status and only ever raise it. A Read also implies delivery, so it fills in the delivered time if that's missing:
public DeviceReceipt Raise(ReceiptStatus incoming, DateTimeOffset at)
{
if (incoming <= Status)
return KeepStatusButBackfillEarlierTimes(incoming, at); // never goes down
return this with
{
Status = incoming,
DeliveredAt = Min(DeliveredAt, at), // Read implies Delivered
// ReadAt / PlayedAt handled the same way
// ... full implementation in the complete project
};
}
Highest status wins, earliest timestamp wins. Because that's order-independent and duplicate-safe, a retry storm can't hurt you either. My favourite test in the project replays the same three receipts in every possible order and checks they all end in the same state.
Q2. Bob has a phone and a laptop. When is it "delivered"?
Short answer: when any of his devices confirms it stored the message. Not when your server wrote it to a socket.
Since multi-device, each linked device has its own connection, and WhatsApp's security whitepaper describes the sending client encrypting a one-to-one message separately for each device. So the unit you track is the device, not the person. A person's status is the max over their devices.
Two details trip people up:
- Pushing the envelope is not delivery. If you count the TCP write, a crashed app still shows two ticks. Wait for the device's own Delivered receipt.
- Snapshot the devices at send time. A laptop Bob links tomorrow was never addressed, so a receipt from it for today's message gets rejected at ingest.
var targets = await _directory.CurrentDevicesAsync(recipients, ct); // frozen here
var message = Message.Accept(senderDevice, clientMessageId, targets, _clock.GetUtcNow());
// idempotent on (senderDevice, clientMessageId); enqueue one envelope per device
// ... full implementation in the complete project
Q3. A 300-person family group. How do you know everyone read it?
Short answer: keep two counters and bump each one at most once per member.
Scanning 300 members on every receipt works in a demo and melts in production, because every receipt for a busy group message fights over the same row. Instead, count the moment a member crosses a threshold:
void OnMemberChanged(ReceiptStatus before, ReceiptStatus after)
{
if (before < ReceiptStatus.Delivered && after >= ReceiptStatus.Delivered) DeliveredCount++;
if (before < ReceiptStatus.Read && after >= ReceiptStatus.Read) ReadCount++;
}
// group is blue when ReadCount == RecipientCount; 1:1 chats are just N = 1
Because Q1 guarantees a member never moves backwards, each counter moves exactly once per member. In the project, a test fires thousands of random receipts at a group of over a thousand members and checks the counters against a full scan.
Q4. Where does the contention go?
Short answer: into one place, on purpose. Partition receipts by message id, and apply them in batches.
If every receipt for a given message lands on the same worker, that worker is the only writer for that message, and a burst of 200 reads in the same instant becomes one load, 200 in-memory updates and one save. Use a stable hash for the routing. string.GetHashCode() is randomized per process in .NET, so two API instances would disagree about where a message lives:
int partition = (int)(Fnv1a64(messageId) % (ulong)partitionCount);
// BackgroundService per partition: drain batch -> group by message -> apply -> save with
// compare-and-swap on revision; on conflict re-read and re-apply (safe because Raise is idempotent)
// ... full implementation in the complete project
Q5. Someone turns read receipts off. What changes, and for whom?
Short answer: it cuts both ways, only in one-to-one chats, and you apply it at two different moments.
The Help Center says turning read receipts off means others won't see your reads, and you won't see theirs. It also says the switch doesn't apply to group chats or to played receipts on voice messages.
So I split it:
- Reader side, at ingest. A Read from someone with receipts off, in a one-to-one chat, gets stored as Delivered. Reading proves the device has the message, and nothing more leaks.
- Viewer side, at read time. A sender with receipts off sees at most two grey ticks in one-to-one chats, whatever is stored.
Doing the viewer side at read time matters because people flip the setting. If you bake visibility into stored state, every toggle becomes a data migration.
Q6. The sender edits the message. Now what?
Short answer: this is the one time a status is allowed to go down, and it has to be explicit.
An edit bumps the message version and moves every device's Read back to Delivered. The tricky bit is the old Read receipt that's still in flight when the edit lands. It proves the device got the message, but not that anyone saw the new text, so it's downgraded to Delivered on arrival:
if (receipt.Version < message.Version && receipt.Status >= ReceiptStatus.Read)
receipt = receipt with { Status = ReceiptStatus.Delivered };
// version check, Message info timestamps and outbox updates omitted
The question I left out
There's a seventh question, and it's the one I'd actually use to separate candidates. Your laptop has been closed for two days while your phone sent dozens of messages to a big group, and each message gathered hundreds of receipts. The laptop opens. Do you replay every receipt event, send a snapshot, or something else, and how do you keep its Message info screen agreeing with the phone next to it? The full project and PDF walk through the answer.
Get the complete project
The full .NET 10 solution is inside Tech Skill Builder, with Domain, Application, Infrastructure and Api as separate projects. It includes the idempotent router with device snapshots, per-device offline queues, the monotonic receipt state machine, partitioned BackgroundService workers with compare-and-swap, the group counters, both sides of the privacy rule, edit resets, coalescing outboxes and 185 passing tests (165 unit, 20 integration). The 95-page PDF covers what WhatsApp has published, the architecture and request-flow diagrams, the trade-offs, and a code walkthrough tied to each part of the design.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10: https://elitesolutions1.gumroad.com/l/TechSkillBuilder
Which of these six would you have answered with a status column? Tell me in the comments.
Top comments (0)