Skip to content

Status Table

The message_status table — tracking tick boundaries per user per conversation

Two integers per conversation per user. Everything else is derived.


Schema#

message_status table (DynamoDB)
───────────────────────────────────────────────────────────
PK: user_id          (who we're tracking status for)
SK: conversation_id  (which conversation)
last_delivered_seq   (double tick boundary)
last_read_seq        (blue tick boundary)

Example rows for Bob across multiple conversations:

user_id    conversation_id    last_delivered_seq    last_read_seq
bob        conv_abc123        44                    42
bob        conv_def456        17                    17
bob        conv_ghi789        8                     8

Reading this: - In conv_abc123, Bob has received messages up to seq=44, but only read up to seq=42. Messages 43 and 44 are delivered but unread (double tick, not blue). - In conv_def456 and conv_ghi789, Bob has both received and read everything up to the latest seq.


Separation of concerns — two tables, two responsibilities#

This table is separate from pending_deliveries. They solve different problems:

pending_deliveries    → "what messages does Bob still need to receive?"
                        used when Bob reconnects after being offline
                        PK=receiver_id, SK=conversation_id, first_undelivered_seq

message_status        → "what is the tick state of Alice's sent messages?"
                        used to show ticks on Alice's side
                        PK=user_id, SK=conversation_id, last_delivered_seq, last_read_seq

pending_deliveries is Bob's inbox state — what he hasn't received yet. message_status is Alice's display state — what tick to show on her messages.

Mixing these two would couple delivery tracking with UI rendering. Keep them separate.


Deriving tick state from the boundaries#

Given these two numbers, the tick state of any message Alice sent is:

def tick_state(seq, last_delivered_seq, last_read_seq):
    if seq <= last_read_seq:
        return BLUE_TICK
    elif seq <= last_delivered_seq:
        return DOUBLE_TICK
    else:
        return SINGLE_TICK

Alice's client receives the two numbers once and applies this logic locally. No per-message queries. No per-message status columns.


Writes to this table#

Three events trigger writes:

1. Message delivered to Bob's device (double tick)

Bob's client acks seq=44 over WebSocket
→ server: UPDATE message_status
    SET last_delivered_seq = 44
    WHERE user_id=bob AND conversation_id=conv_abc123

2. Bob opens the chat (blue tick)

Bob opens conv_abc123
→ Bob's client sends: "read up to seq=44"
→ server: UPDATE message_status
    SET last_read_seq = 44
    WHERE user_id=bob AND conversation_id=conv_abc123

3. Bob replies (implicit read)

Bob sends a message in conv_abc123
→ all of Alice's previous messages are implicitly read
→ server: UPDATE message_status
    SET last_read_seq = latest_seq_before_bob_reply
    WHERE user_id=bob AND conversation_id=conv_abc123

All three are single-row updates. One write per event, regardless of how many messages are in the conversation.


Initial row creation#

When Alice sends the first message in a conversation and Bob is offline, there is no row in message_status yet. The row is created on first delivery ack or first read event — whichever comes first.

Interview framing

"Instead of a status column per message, we track two sequence number boundaries per user per conversation — last_delivered_seq and last_read_seq. Tick state for any message is derived from these two numbers at render time. Every status event is a single row update, not a bulk operation."