Low Level Design: Read Receipts Service

What Are Read Receipts?

Read receipts tell message senders whether their messages have been delivered and read. The feature is common in consumer messaging apps but also appears in enterprise tools. The low-level design involves multi-state tracking, bulk marking APIs, fan-out to senders, privacy controls, and storage patterns that hold up under write-heavy workloads.

Message Status States

A message moves through three states:

  • Sent: The server has accepted and stored the message.
  • Delivered: The recipient device has acknowledged receipt — typically confirmed by a push notification delivery callback or a client-to-server ACK upon app foreground.
  • Read: The user has opened the conversation and the message was visible in the viewport.

Each state transition is one-directional and monotonic — a message cannot go from read back to delivered.

Storage Schema

For direct messages, the simplest schema tracks status per recipient:

message_receipts
  message_id       BIGINT
  recipient_id     BIGINT
  delivered_at     TIMESTAMP NULL
  read_at          TIMESTAMP NULL
  PRIMARY KEY (message_id, recipient_id)

This works cleanly for 1:1 chats. For group messages with many recipients, the table grows proportionally with group size times message count. Optimization strategies are covered below.

Delivery Receipt Flow

When the server stores a message, it sets status to sent. Delivery acknowledgment happens through one of two paths:

  • Push notification callback: APNs and FCM provide delivery receipts via webhooks. On callback, the server updates delivered_at.
  • Client ACK: When the app opens and fetches messages over WebSocket or HTTP, it sends an ACK for each received message_id. Server updates delivered_at on ACK.

Read Receipt Flow

Read status is set when the user actively views the message. The client fires a read event when the conversation is opened and the message is within the visible scroll area. To avoid excessive writes, clients use a bulk mark API rather than individual per-message calls.

Bulk Read Marking API

The client sends a single request to mark all messages in a conversation as read up to a given cursor:

POST /conversations/{conversation_id}/read
Body: {up_to_message_id: 9847361}

The server issues a single UPDATE with a range condition rather than individual row updates per message. This dramatically reduces write amplification when opening a conversation with many unread messages.

Fan-Out to Senders

After updating read status, the server must notify the original sender. If the sender has an active WebSocket connection, the server pushes a receipt event directly:

{type: read_receipt, conversation_id, reader_id, up_to_message_id, read_at}

If the sender is offline, the receipt is queued and delivered on next connection. In multi-node setups, the same Redis Pub-Sub or Kafka pattern used for typing indicators applies here — the node that processes the read update publishes a receipt event to the sender-specific channel, and the node holding the sender’s connection delivers it.

Privacy Controls

Many apps allow users to disable read receipts. When a user opts out, the server skips the fan-out step — it still records read_at internally for its own purposes (e.g., analytics, unread count accuracy) but does not send the event to senders. The opt-out flag is checked at fan-out time, not at storage time.

An important edge case: if user A has read receipts enabled and user B has them disabled, user A does not see read receipts for messages they send to user B. Opt-out is applied to the reader, not the sender.

Storage Optimization for Group Messages

In group chats with large membership, tracking per-recipient per-message status creates a massive write load. Two optimization approaches:

  • Cursor-based read tracking: Instead of marking each message individually, store a single read cursor per (conversation_id, user_id) — the highest message_id the user has seen. Any message below the cursor is considered read. This compresses N message rows into one row per user per conversation.
  • Compact bit vectors: For small fixed-size groups (e.g., up to 64 members), a bitmask per message_id represents which members have read it. A single 8-byte integer covers 64 recipients. Read status for a member is a bitmask AND operation.

Scalability Under Write-Heavy Load

Read receipts are write-heavy — every message view generates a write. Strategies to handle scale:

  • Bulk mark API reduces per-message writes to per-conversation writes
  • Write to a fast store (Redis) first, async flush to the database
  • Partition the receipts table by conversation_id to distribute load
  • Batch receipt fan-out events rather than one WebSocket push per message

Read receipts are a good example of a feature where the storage model choice — per-message rows vs. cursor vs. bit vector — has outsized impact on system performance at scale.

Frequently Asked Questions

What is a read receipts service and how does it work?

A read receipts service tracks and broadcasts the delivery and read status of messages in a messaging system. When a message is delivered to a recipient’s device, the client sends a delivery acknowledgment; when the recipient opens the conversation and the message enters the viewport, the client sends a read acknowledgment. The service persists these status updates, updates any sender-visible receipt indicators (single checkmark for sent, double checkmark for delivered, filled checkmark or avatar thumbnail for read), and pushes the updated status back to the original sender’s connected client in real time.

What is the difference between delivered and read status?

Delivered means the message has reached the recipient’s device — confirmed by the client SDK sending an ACK to the server after successful local persistence. The server does not need the user to open the app; the ACK fires automatically when the push notification is processed. Read means the user has actively viewed the message — confirmed by the client sending a read receipt only when the conversation is foregrounded and the message is visible on screen. The distinction matters because a delivered message may sit unread for hours, while a read receipt signals genuine attention. Some users disable read receipts for privacy (WhatsApp, iMessage support this), which means the service must respect a per-user privacy setting and suppress read ACKs or hide them from senders accordingly.

How do you implement bulk read marking for conversation read status?

Sending an individual ACK for every unread message in a conversation when the user opens it is wasteful — a user returning after a day away might have hundreds of unread messages. Instead, clients send a single “mark conversation read up to message ID X” event, where X is the ID of the most recent message visible. The server updates a per-user, per-conversation watermark: last_read_message_id. Any message with an ID less than or equal to the watermark is considered read. Unread counts are derived by counting messages with IDs above the watermark. This reduces the write volume from O(unread message count) to O(1) per conversation open, and the watermark model is naturally idempotent — replaying the same event produces the same state.

How do you scale read receipt writes for high-volume messaging?

Read receipt writes are high-volume and bursty: every message open by every user generates a write. Scaling strategies include: (1) write-behind caching — buffer receipt updates in Redis and flush to the persistent store (Cassandra, DynamoDB) in micro-batches, trading strict durability for throughput; (2) coalescing — if multiple receipt updates arrive for the same conversation within a short window, merge them into a single write using the maximum message ID (last-writer-wins); (3) sharding the receipts table by user ID so writes are distributed across nodes; (4) using an append-only log (Kafka) as the write path, with consumers materializing the current watermark state into the read store. For group chats with many recipients, fanout of the read receipt to the sender is often throttled or sampled — large groups may show “N people read this” counts rather than per-user status to reduce fanout amplification.

See also: Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering

See also: Snap Interview Guide

See also: LinkedIn Interview Guide 2026: Social Graph Engineering, Feed Ranking, and Professional Network Scale

Scroll to Top