Skip to content
José da Cruz

Enterprise Architect, and Life Thinker

José da Cruz

Enterprise Architect, and Life Thinker

How ‘Seen’ Receipts Actually Work: Avoiding the Write Amplification Trap

josedacruz, August 9, 2026August 9, 2026

TL;DR: A “seen” checkmark looks like the simplest feature in a chat app, but the naive way to build it — storing a read flag for every message and every recipient — creates a database write explosion that gets worse as your group chats grow. The fix most real apps use is to track a single “last read” pointer per user instead of a flag per message. It’s a small change in modeling that turns an O(messages x members) write problem into an O(members) one.

What’s actually hard about a “seen” checkmark?

Nothing about the checkmark itself is hard. The hard part is the database load hiding behind it. Say you’re on a small team building a messaging app, and product asks for “read receipts, like WhatsApp.” Someone on the team writes the obvious version: every message gets a list of who has seen it. When a user opens a chat, you add their user ID to that list. Simple to build, simple to explain in a design review.

Then the app grows. A few power users create group chats with 200 people. Suddenly that “simple” feature is doing hundreds of database writes every time someone glances at their phone. This is the moment most teams first hear the term write amplification: one user action (opening a chat) turning into many more writes than you expected.

Why can’t I just add a “seen_by” list to every message?

You can, and it works fine in a demo. The problem shows up at scale, in two ways.

First, the math. If a group has N members and someone sends a message, in the worst case you eventually do N separate “mark as read” writes for that one message — one per member, whenever each of them happens to open the chat. Now multiply that by every message in an active group chat. A 500-person group with a lively conversation can generate write volume that’s an order of magnitude higher than a 5-person one, even though both groups are “just chatting.”

Second, the read side gets expensive too. To show “seen by 12 people” or to compute someone’s unread count, you now have to scan and count entries across a growing list attached to every message, instead of doing one cheap lookup.

Chart comparing write volume for per-user flags versus a last-read pointer as group size grows
Per-message, per-user flags scale with group size. A single pointer per user does not.

How do apps like WhatsApp and Slack actually track it?

Instead of asking “has this specific message been seen by this specific person,” they flip the question around to “what is the last message this person has seen?” Each reader gets exactly one value stored: a pointer (usually just a message ID or timestamp) marking how far they’ve read in that conversation.

When Alice opens a chat, the app does one write: update Alice’s last-read pointer to the newest message ID in that chat. That’s it — one write, no matter how many messages are in the conversation or how many other people are in the group.

To figure out whether a given message has been seen by someone, you don’t look the message up in a list. You compare: is that person’s pointer past this message’s position in the conversation? If yes, they’ve seen it. To compute someone’s unread count, you count how many messages exist after their pointer. Both of these are cheap, fast comparisons instead of scans through growing lists.

Sequence diagram showing a reader updating a last-read pointer and a sender querying it
One write when you open the chat. Everyone else’s “has this been read” check becomes a comparison, not a write.

What about group chats with hundreds of members?

This is exactly where the pointer approach earns its keep. With per-message flags, a 300-person group chat means every message write potentially fans out into 300 future writes as people trickle in and read it. With a pointer per member, it’s still just 300 total pointers — but each one only gets touched when that specific person actually opens the chat, and each update touches exactly one row.

The “seen by 12 people” list you see under a message in some apps is usually computed on demand, not stored per message. The app looks at everyone’s pointer, checks who’s passed this message’s position, and builds that list at read time. It costs a little more when you actually ask “who has seen this,” but that’s a much rarer action than “did I update my own read position,” so it’s the right thing to make expensive.

Does this affect anything besides storage?

Yes — it changes what you can build later. Once you store a pointer instead of a flag, you get unread counts almost for free: it’s just “messages newer than my pointer.” With the per-message-flag model, computing an unread count means scanning every message and checking whether your ID is in its list, which gets slower as the chat history grows.

The pointer model also plays nicer with how chat apps actually work in practice. Most people don’t read messages one at a time — they open a chat and skim the last twenty messages at once. A single pointer update captures that reality directly. A per-message flag system has to pretend the user read each message individually, which was never really true.

One trade-off worth knowing: the pointer model can’t easily tell you “did this specific person skip this one message and read the next one instead,” because it only tracks how far someone has read, not which exact messages they looked at. In practice, almost no product needs that level of detail, but it’s worth knowing you’re giving it up.

What’s the practical takeaway for building this myself?

If you’re designing a read-tracking feature, start by asking what you actually need to answer: “has this exact message been seen,” or “how far has this person read.” Most products only need the second one, and that’s the version that scales. Model the state per reader, not per message, and let the expensive “who has seen this” view be computed on demand instead of stored and maintained on every write.

More generally, this is a pattern worth recognizing outside of chat apps too: any time you’re tempted to store a flag on one side of a many-to-many relationship (every message times every reader, every document times every viewer), ask whether you can instead store a single pointer or watermark on the smaller side. It’s the same trick behind things like “unread” counts in RSS readers and “last viewed” markers in shared documents. The checkmark stays the same for the user. The data model underneath just gets a lot cheaper to run.

Related

architecture case studychat appsdata modelingscalabilitysystem design

Post navigation

Previous post
Next post

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

  • architecture (154)
  • artificial-intelligence (6)
  • books (2)
  • courses (4)
  • decision-frameworks (4)
  • finances (1)
  • java (8)
  • life-improvement (20)
  • metrics (3)
  • observability (2)
  • puzzles and challenges (3)
  • reviews (1)
  • security (8)
  • spring-boot (36)
  • Systems Thinking (3)
  • GitHub Weekly: MCP Tools Trending Right Now (September 28 – October 4, 2026)
  • The Bulkhead Pattern: Why One Slow Dependency Shouldn’t Sink the Whole Ship
  • Essential Reading: A Philosophy of Software Design — Fighting Complexity One Module at a Time
  • GitHub Weekly: Top 10 Trending Repos (September 21 – September 27, 2026)
  • The Volunteer Tax: Why Raising Your Hand in a Meeting Makes You the Permanent Owner
©2026 José da Cruz | WordPress Theme by SuperbThemes