Patterns

Read Cursor Receipts

Represent read state as a user's latest read sequence in a conversation instead of writing one read receipt per message.

intermediate4 min readUpdated unknownDataReliabilityOperationsTradeoffs
Message ReceiptsRead CursorDerived ProjectionsUnread CountsEventual Consistency

Concepts Covered

  • Read receipts
  • Read cursors
  • Read-up-to sequence
  • Unread projections
  • Receipt batching
  • Projection drift
  • Reconciliation
  • Multi-device read state

1. Intent

The Read Cursor Receipts pattern stores read state as "this user has read up to sequence N" rather than writing one read record for every message.

In chat systems, users often read a batch of messages at once. If a user opens a conversation with 40 unread messages, the system usually does not need 40 separate read events.

One cursor can express the same product state:

user u_7 read conversation c_10 up to sequence 84211

This reduces write volume and makes unread count computation easier.

2. The Problem Without This Pattern

Per-message read receipts create heavy write amplification.

Imagine a group with 10,000 members and a burst of 100 messages. If every member eventually reads every message and the system records one row per message per user, the receipt table grows quickly.

The write path, event stream, projection workers, and unread counters all absorb that load.

Most chat UIs only need to know the latest contiguous position a user has read. A cursor captures that directly.

3. How The Pattern Works

The message log has ordered sequences:

message m_1 -> sequence 100
message m_2 -> sequence 101
message m_3 -> sequence 102

The receipt service stores:

conversation_read_state
- user_id
- conversation_id
- read_up_to_sequence
- read_at

When the user opens the conversation, the client sends:

mark_read(conversation_id=c_10, read_up_to_sequence=84211)

The server validates that the user is a member and that the sequence exists. It then updates the cursor if the new sequence is ahead of the old one.

The update should usually be monotonic:

new_cursor = max(existing_cursor, requested_cursor)

This prevents stale devices from moving read state backward.

4. When To Use It

Use this pattern when:

  • read state is mostly sequential
  • unread counts matter
  • per-message receipts would be too expensive
  • conversations have ordered sequences
  • clients can batch read updates
  • read state feeds derived projections
  • users may read multiple messages at once

It is especially useful for inboxes, chat conversations, notification feeds, and activity streams.

5. When Not To Use It

Avoid this pattern when:

  • users can read arbitrary messages out of order and the product must track exact gaps
  • every message requires an individual audit acknowledgement
  • legal or compliance rules require per-item read evidence
  • there is no stable ordering boundary
  • read state should remain purely local to one device

You can combine read cursors with exceptions, but that adds complexity.

6. Data And Operational Model

Core row:

read_state
- user_id
- conversation_id
- read_up_to_sequence
- updated_at

Derived projection:

inbox_projection
- user_id
- conversation_id
- unread_count
- latest_message_sequence
- read_up_to_sequence

Operators should monitor:

  • receipt event lag
  • unread projection drift
  • read update rate
  • duplicate read events
  • cursor regression attempts
  • reconciliation corrections
  • privacy-filtered read receipts

The read cursor may be user-level or device-level depending on product rules. If reading on laptop should clear unread on phone, user-level cursor is natural. If each device keeps independent read state, device-level cursor is needed.

7. Failure Modes

  • A stale client moves the cursor backward.
  • A client marks messages read before rendering them safely.
  • Unread counts drift because projection workers miss receipt events.
  • Membership changes make a cursor point to messages the user should not see.
  • A read receipt leaks privacy state when the user disabled read receipts.
  • Concurrent devices race to update read state.
  • Cursor update is accepted for a sequence that does not belong to the conversation.

8. Tradeoffs

BenefitCost
Reduces receipt write amplificationDoes not capture arbitrary read gaps
Makes unread counts cheaperRequires ordered conversation sequences
Supports batching naturallyProjection drift still needs repair
Works well across devicesProduct semantics must be precise
Simplifies read stateNeeds monotonic update rules

Read cursor receipts compress a noisy stream of per-message actions into one meaningful progress marker.

Knowledge links

Use these links to understand what to know first, where this idea appears, and what to study next.

Prerequisites

Read these first if this topic feels unfamiliar.

Used In Systems

System studies where this idea appears in context.

Related Concepts

Core ideas that connect to this topic.

Related Patterns

Reusable architecture moves built from these ideas.