A message moves through three observable states for the sender: sent (one grey tick), delivered (two grey ticks), read (two blue ticks). Each transition is a small control message flowing back the way the message came.
State machine
client send server ack recipient receives recipient opens chat
─────────────────► ────────────────────► ────────────────────────► ────────────────────────►
(in flight) ✓ sent ✓✓ delivered ✓✓ read (blue)
The transitions are monotonic. A message can’t go from delivered back to sent. The client’s local UI follows the highest state it has seen and ignores out-of-order updates.
Delivered
Architecture
flowchart TD
Client_B(["Client B"])
GW_B["Edge Gateway B
(EC2, WebSocket)"]
RS["Receipt Service
(Fargate)"]
Status[("MessageStatus
(DynamoDB)
PK message_id
SK recipient_id")]
GW_A["Edge Gateway A
(EC2, WebSocket)"]
Client_A(["Client A"])
Sess[("Session Store
(Redis)")]
Client_B -->|"① DELIVERED ack
for msg_id"| GW_B
GW_B --> RS
RS -->|"② upsert"| Status
RS -->|"③ lookup sender"| Sess
RS -->|"④ push"| GW_A
GW_A --> Client_A
When the recipient’s client receives a message and persists it locally (chapter 5), it sends a DELIVERED control frame back up its socket. The Receipt Service writes a row keyed by (message_id, recipient_id) with status = delivered, delivered_at = now() and then pushes a STATUS frame back to the original sender via the session store.
The MessageStatus row is small and mutates a few times — sent on insert, delivered on first ack, read later. A DynamoDB table with partition key message_id and sort key recipient_id handles it. For 1:1 chats this is one row per message; for groups it’s N rows, one per recipient (chapter 9).
Why a separate table instead of mutating the message row in the message store? Two reasons. First, for groups the status is per-recipient — one message produces N status rows — and packing N rows into one message row breaks the simple (conversation_id, message_id) access pattern that chapter 4’s history reads depend on. Second, status mutates several times per message while the message body is write-once; mixing a write-once column with a hot-updated one inflates the cost of every history scan. A relational table joined back to the message row is plausible at small scale but would force a cross-shard join once messages is partitioned, which is exactly what the chapter 4 model is built to avoid.
If the sender is offline when the receipt arrives, the STATUS frame goes into the sender’s pending queue (chapter 5) and is delivered on reconnect. Receipts are messages too.
Read
Architecture
flowchart TD
Client_B(["Client B"])
GW_B["Edge Gateway B
(EC2, WebSocket)"]
RS["Receipt Service
(Fargate)"]
Status[("MessageStatus
(DynamoDB)")]
GW_A["Edge Gateway A
(EC2, WebSocket)"]
Client_A(["Client A"])
Client_B -->|"① READ batch
up to last_read_id"| GW_B
GW_B --> RS
RS -->|"② bulk upsert"| Status
RS -->|"③ push high-water"| GW_A
GW_A --> Client_A
When the recipient opens the chat, they don’t send one READ per message. The client batches: “I’ve read everything up to message_id = X.” The Receipt Service updates every status row in that conversation with id ≤ X to read, and pushes a single READ control frame to the sender carrying the high-water mark.
Batching matters because reading a long unread chat can mean hundreds of messages. One control message per chunk, not per message. The sender’s UI, on receiving the high-water mark, walks its local store and flips every message in the conversation up to that ID to blue.
Why receipts share the message channel
Receipts could be a separate REST endpoint. Don’t make them one. Putting them on the same socket as messages gives:
- One transport to debug. The framing, retry, and ordering rules already exist for messages.
- Push back to the sender. The session store already knows where the sender’s socket is — there’s no need to invent a second push path just for receipts.
- Free batching. Multiple receipts coalesce into a single frame; a separate HTTP path would mean per-receipt request overhead.
The cost is that receipts inherit the same delivery semantics as messages: at-least-once with client-side dedup. That’s fine — the receipt itself is idempotent (you can apply delivered twice and the row doesn’t care).
What the sender sees
The tick under each message updates as soon as the corresponding STATUS frame arrives. Local-only — no extra network call, no re-fetch. If the sender goes offline after sending, the receipts queue up and arrive on reconnect, and the ticks update in a small wave when the app comes back.