In a chat app with 5,000-member groups, how would you store read state so unread counts and read receipts stay cheap?
answer
- messages times members
- one number per reader
- latest minus last read
- forward-only update
- compute receipts on demand
basics
~20 sStore a read cursor, the highest sequence read, for each member and conversation, not a row per message per reader. The unread count is the latest sequence minus the cursor. Receipts come from comparing cursors, computed on demand and limited in large groups.
solid answer
~50 sPer-message, per-reader receipts grow as **messages x members**. A 5,000-member group with 200 messages a day creates 1,000,000 receipt rows a day, and every read also fans out an update to 5,000 people. Instead, store one **read cursor** per `(user, conversation)`: the highest sequence the user has read. Cursors only move forward (`max(old, new)`), which absorbs updates arriving out of order from several devices. **Unread count** = `latest_seq - read_cursor`, computed without scanning messages. The user's own sends advance their cursor so they do not count. A **receipt** for message *s* is "members whose cursor is at least *s*". In a 1:1 chat that is one comparison. In a big group, compute it **on demand** when someone opens message info, or show only an approximate count. Clients **debounce** cursor updates while the user scrolls, and servers do not fan out every read event to large groups.
code
sql · 5 linesSELECT COUNT(*)
FROM read_cursor
WHERE conversation_id = :c
AND last_read_seq >= :seq
AND user_id <> :sender;go deeper
Remember that read state can be a single number per person per chat, the last message they read, rather than a mark on every message.
Explain how the unread count comes from latest sequence minus read cursor, and why the cursor update must only move forward.
Quantify the messages-times-members blow-up, then show debouncing, on-demand receipt counts, cached badge totals and multi-device sync.
Treat receipt fidelity in large groups as a product and cost decision: full lists, counts or nothing, set by a group-size threshold.
## Two features, one data model **Unread counts** answer "how many messages in this chat have I not seen?" **Read receipts** answer "who has seen my message?" A naive design stores a row for every (message, reader) pair and serves both features from it. That works in a 1:1 chat and collapses in large groups. ## Why per-message receipts explode The row count grows as **messages x members**. As an illustration, assume a 5,000-member group that receives 200 messages a day: - 200 x 5,000 = **1,000,000** receipt rows a day for that single group; - every read by one member must write rows for each message they scrolled past; - if receipts are pushed live, every read triggers an update to up to 5,000 other members; - counting unread messages means counting rows, which gets slower as the backlog grows. Write volume, storage and fanout all grow with group size, while almost nobody reads a 5,000-person receipt list. ## The read cursor Messages already carry a **per-conversation sequence number**, so read state can be compressed into one number per member: ```sql CREATE TABLE read_cursor ( user_id BIGINT NOT NULL, conversation_id BIGINT NOT NULL, last_read_seq BIGINT NOT NULL, updated_at TIMESTAMP NOT NULL, PRIMARY KEY (user_id, conversation_id) ); -- advance only forward UPDATE read_cursor SET last_read_seq = GREATEST(last_read_seq, :seq), updated_at = :now WHERE user_id = :u AND conversation_id = :c; ``` - **One row per member per conversation**, however many messages exist. - **Forward-only updates** (`max(old, new)`): a phone and a laptop can report reads in any order without moving the cursor back. - **Own messages** advance the sender's cursor when sent, so they never count as unread. ## Deriving both features | Feature | Per-message rows | Read cursor | |---|---|---| | Unread count for one chat | count the unread rows | `latest_seq - last_read_seq` | | Total badge across chats | count over all chats | sum of per-chat differences, cached | | 1:1 "read" tick | look up one row | peer cursor >= seq | | Group "read by N" | count the rows | count members with cursor >= seq | | Write cost of one read | one row per message read | one cursor update | The unread count is an approximation when deleted messages leave tombstones in the sequence. Products either accept that or subtract a per-conversation deleted-count. ## Keeping receipts cheap in large groups 1. **Debounce on the client**: while the user scrolls, send only the highest sequence seen, at most every few seconds. 2. **Compute on demand**: "read by 1,243" is computed when someone opens message info, by counting cursors at or above that sequence, not maintained per message. 3. **Limit by size**: many products show full per-person receipts only in small groups, and aggregate counts or nothing in large ones. 4. **No fanout of every read**: in big groups, do not push each cursor move to all members. A sender who has the info screen open can subscribe to updates for that one message. ## Other details that matter - **Multi-device**: the cursor is per user, not per device, so reading on the laptop clears the phone's badge. Send a silent sync to the other devices. - **Badge totals**: a user in hundreds of chats should not trigger hundreds of subtractions per push. Cache a per-user unread total and adjust it when a cursor or a latest sequence changes, and rebuild it now and then to correct drift. - **Delivered and read**: "delivered" can use the same idea with a per-device delivered cursor, advanced by the device ack. - **Privacy**: if a user turns off read receipts, their cursor still drives their own unread count but is never shown to others. The core idea is that ordered sequence numbers turn "which messages did I read" into a single number, so storage grows with membership instead of message volume.
- Why must a read cursor update be forward-only?A user's phone and laptop both report reads, and those reports can arrive out of order. With a plain overwrite, a late report from the phone at sequence 90 could move the cursor back from 120 and make read messages unread again. Using max(old, new) makes the updates order-independent.
- How would you show the total unread badge for a user in 300 conversations?Do not recompute 300 differences on every event. Keep a cached per-user total, adjust it when one of the user's cursors moves or a conversation's latest sequence advances, and rebuild it from the cursors now and then to correct drift. Push the cached value as the badge count.
saying these in an interview costs you the question
- Store a row for each message each member has read.
- Unread count is computed by counting unread message rows on each request.
- Overwrite the read cursor with whatever value the device sends.
- Push every member's read event to the whole group in real time.
- Read receipts should behave the same in a 3-person and a 5,000-person group.