One-on-one chat system with presence and offline queue

0
Remix

Design direct messaging for single-device users: delivery, presence, offline-recipient queueing, session storage, and the choice of message bus. Group chat is explicitly out of scope.

AnthropicSystem designMediumLast asked Oct 2026

What L5 looks like

  • Defines per-conversation sequence numbers and idempotent sends with client message ids, plus device-side dedupe.
  • Uses a per-user inbox with a cursor for sync, and orders writes so "sent" means durable.
  • Designs presence from connection events with a grace period and subscription-based fan-out; justifies the bus choice.
Interviewer expectations for every level ↓

Requirements

Direct messages between two people, each signed in on one device at a time. Group chat, multi-device sync and end-to-end encryption are out of scope (say how the design would change, briefly, at the end). Questions to settle first: text only or media too (text here; media goes to object storage by reference)? How long is history kept (forever, for the user to scroll)? Is "delivered" and "read" status needed (yes)? Must presence be exact (no: a few seconds stale is fine)?

Functional requirements

  • Send a message to another user; it appears on their device within a second when they are online.
  • If the recipient is offline, keep the message and deliver it when they reconnect, plus a push notification.
  • Per-conversation order: both people see messages in the same order.
  • Message states: sent (stored by the server), delivered (on the recipient's device), read.
  • Presence: online, or "last seen at" for contacts.
  • Scroll back through history.

Non-functional requirements

  • p99 under 500 ms from send to display for online recipients in the same region.
  • No message lost once the sender sees "sent"; duplicates are filtered on the device.
  • 99.99% availability for sending; presence may degrade first under load.
  • Horizontally scalable to hundreds of millions of users.

Back-of-the-envelope estimation

  • 100M daily active users, 40 messages sent per user per day: 4B messages/day, about 46k/s average and ~140k/s at a 3× peak.
  • Storage: ~300 bytes per message with metadata. 4B × 300 B = 1.2 TB/day, 3.6 TB/day with 3 replicas, ~1.3 PB/year replicated.
  • Connections: 30% of DAU online at peak = 30M open WebSocket connections. At 100k connections per gateway host (memory ~20–30 KB each, ~3 GB), that is 300 gateways, run 450 for headroom and zone failure.
  • Presence: if every client sent a heartbeat every 30 s, that is 1M writes/s for nothing new. The design uses connect and disconnect events from gateways instead, a few hundred thousand per second at most during reconnect storms and usually far fewer.

API design

Client ↔ gateway (one WebSocket per device; JSON or protobuf frames):

→ send      {client_msg_id, to_user, body, sent_at_client}
← ack       {client_msg_id, conversation_id, seq, server_ts}           -- "sent"
← message   {conversation_id, seq, from_user, body, server_ts, inbox_seq}
→ delivered {inbox_seq}                                                 -- cumulative
→ read      {conversation_id, seq}                                      -- cumulative
← receipt   {conversation_id, seq, state: delivered | read}
→ sync      {after_inbox_seq}  on (re)connect, paged
← presence  {user, state: online | offline, last_seen}

HTTP for history: GET /v1/conversations/{id}/messages?before_seq=&limit=50.

client_msg_id (a UUID the device generates) makes sending idempotent: a retry after a dropped connection returns the original seq instead of storing a second copy.

Data model

Messages, in a wide-column store (Cassandra or ScyllaDB), partitioned by conversation:

Column Notes
conversation_id (partition key) hash of the two user ids, sorted, so both sides compute the same id
seq (clustering, descending) per-conversation sequence number, assigned by the conversation's owner
from_user, body, server_ts, client_msg_id

Partitions grow forever for very chatty pairs; bucket by month (conversation_id, month) to keep partitions under ~100 MB.

Inbox, per user, the delivery log the device syncs from:

Column Notes
user_id (partition key), inbox_seq (clustering) per-user monotonic counter
conversation_id, seq points at the message

Every message is written once to messages and once to the recipient's inbox (and the sender's, so their other future devices could sync). A device needs exactly one cursor, last_delivered_inbox_seq, to fetch everything it missed, whatever conversation it was in. The "offline queue" is simply the part of the inbox after the cursor.

Cursors and receipts: delivery_state(user_id) → last_delivered_inbox_seq and read_state(user_id, conversation_id) → last_read_seq.

Sessions (Redis, in memory, TTL refreshed by the gateway): session:{user_id} → {gateway_id, connection_id, connected_at}.

Presence (Redis): presence:{user_id} → online | last_seen_ts.

High-level design

Build it one requirement at a time. Each drawing adds to the one before; the shaded boxes are what that step adds.

1) Deliver to an online recipient

The sender's gateway forwards send to the chat service. The chat service assigns seq, stores the message, acks the sender, looks up the recipient's gateway in the session registry, and pushes the message to it.

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"sender","x":0,"y":0,"width":180,"height":70,"label":{"text":"Sender app"}},
 {"type":"rectangle","id":"gw","x":340,"y":0,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"chat","x":680,"y":0,"width":180,"height":70,"label":{"text":"Chat service"}},
 {"type":"ellipse","id":"store","x":1020,"y":-5,"width":200,"height":80,"label":{"text":"Message store"}},
 {"type":"ellipse","id":"reg","x":1020,"y":175,"width":200,"height":80,"label":{"text":"Session\nregistry"}},
 {"type":"rectangle","id":"rgw","x":340,"y":180,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"recipient","x":0,"y":180,"width":180,"height":70,"label":{"text":"Recipient app"}},
 {"type":"arrow","id":"s1","x":180,"y":35,"width":160,"height":0,"start":{"id":"sender"},"end":{"id":"gw"},"label":{"text":"WebSocket"}},
 {"type":"arrow","id":"s2","x":520,"y":35,"width":160,"height":0,"start":{"id":"gw"},"end":{"id":"chat"},"label":{"text":"send"}},
 {"type":"arrow","id":"s3","x":860,"y":35,"width":160,"height":0,"start":{"id":"chat"},"end":{"id":"store"},"label":{"text":"append"}},
 {"type":"arrow","id":"s4","x":860,"y":60,"width":180,"height":140,"start":{"id":"chat"},"end":{"id":"reg"}},
 {"type":"arrow","id":"s5","x":700,"y":70,"width":-180,"height":140,"start":{"id":"chat"},"end":{"id":"rgw"}},
 {"type":"arrow","id":"s6","x":340,"y":215,"width":-160,"height":0,"start":{"id":"rgw"},"end":{"id":"recipient"},"label":{"text":"push"}}
]}

2) Keep messages for offline recipients

Every message also goes into the recipient's inbox. On reconnect, the device sends sync with its cursor and receives what it missed, in pages. If the recipient had no session, the chat service publishes an event that the push service turns into a notification.

{"type":"excalidraw","elements":[
 {"type":"text","id":"legend","x":0,"y":-60,"text":"Shaded: added in this step","fontSize":16},
 {"type":"rectangle","id":"sender","x":0,"y":0,"width":180,"height":70,"label":{"text":"Sender app"}},
 {"type":"rectangle","id":"gw","x":340,"y":0,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"chat","x":680,"y":0,"width":180,"height":70,"label":{"text":"Chat service"}},
 {"type":"ellipse","id":"store","x":1020,"y":-5,"width":200,"height":80,"label":{"text":"Messages +\ninbox"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"ellipse","id":"reg","x":1020,"y":175,"width":200,"height":80,"label":{"text":"Session\nregistry"}},
 {"type":"rectangle","id":"rgw","x":340,"y":180,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"recipient","x":0,"y":180,"width":180,"height":70,"label":{"text":"Recipient app"}},
 {"type":"rectangle","id":"push","x":680,"y":360,"width":180,"height":70,"label":{"text":"Push service"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"rectangle","id":"apns","x":340,"y":360,"width":180,"height":70,"label":{"text":"APNs / FCM"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"arrow","id":"s1","x":180,"y":35,"width":160,"height":0,"start":{"id":"sender"},"end":{"id":"gw"},"label":{"text":"WebSocket"}},
 {"type":"arrow","id":"s2","x":520,"y":35,"width":160,"height":0,"start":{"id":"gw"},"end":{"id":"chat"},"label":{"text":"send"}},
 {"type":"arrow","id":"s3","x":860,"y":35,"width":160,"height":0,"start":{"id":"chat"},"end":{"id":"store"},"label":{"text":"append"}},
 {"type":"arrow","id":"s4","x":860,"y":60,"width":180,"height":140,"start":{"id":"chat"},"end":{"id":"reg"}},
 {"type":"arrow","id":"s5","x":700,"y":70,"width":-180,"height":140,"start":{"id":"chat"},"end":{"id":"rgw"}},
 {"type":"arrow","id":"s6","x":340,"y":215,"width":-160,"height":0,"start":{"id":"rgw"},"end":{"id":"recipient"},"label":{"text":"push"}},
 {"type":"arrow","id":"s7","x":800,"y":70,"width":1,"height":290,"start":{"id":"chat"},"end":{"id":"push"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"s8","x":680,"y":395,"width":-160,"height":0,"start":{"id":"push"},"end":{"id":"apns"},"label":{"text":"notify"}},
 {"type":"arrow","id":"s9","x":400,"y":360,"width":-310,"height":-110,"start":{"id":"apns"},"end":{"id":"recipient"},"strokeStyle":"dashed"}
]}

3) Presence

Gateways already know when a connection opens and closes. They report those events to the presence service, which writes presence:{user} and publishes changes only to users who are currently looking at that contact (a subscription the client opens when a chat screen is visible).

{"type":"excalidraw","elements":[
 {"type":"text","id":"legend","x":0,"y":-60,"text":"Shaded: added in this step","fontSize":16},
 {"type":"rectangle","id":"sender","x":0,"y":0,"width":180,"height":70,"label":{"text":"Sender app"}},
 {"type":"rectangle","id":"gw","x":340,"y":0,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"chat","x":680,"y":0,"width":180,"height":70,"label":{"text":"Chat service"}},
 {"type":"ellipse","id":"store","x":1020,"y":-5,"width":200,"height":80,"label":{"text":"Messages +\ninbox"}},
 {"type":"ellipse","id":"reg","x":1020,"y":175,"width":200,"height":80,"label":{"text":"Session\nregistry"}},
 {"type":"rectangle","id":"rgw","x":340,"y":180,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"recipient","x":0,"y":180,"width":180,"height":70,"label":{"text":"Recipient app"}},
 {"type":"rectangle","id":"push","x":680,"y":360,"width":180,"height":70,"label":{"text":"Push service"}},
 {"type":"rectangle","id":"apns","x":340,"y":360,"width":180,"height":70,"label":{"text":"APNs / FCM"}},
 {"type":"rectangle","id":"pres","x":340,"y":-200,"width":180,"height":70,"label":{"text":"Presence\nservice"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"ellipse","id":"pstore","x":670,"y":-205,"width":200,"height":80,"label":{"text":"Presence\n(Redis)"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"arrow","id":"s1","x":180,"y":35,"width":160,"height":0,"start":{"id":"sender"},"end":{"id":"gw"},"label":{"text":"WebSocket"}},
 {"type":"arrow","id":"s2","x":520,"y":35,"width":160,"height":0,"start":{"id":"gw"},"end":{"id":"chat"},"label":{"text":"send"}},
 {"type":"arrow","id":"s3","x":860,"y":35,"width":160,"height":0,"start":{"id":"chat"},"end":{"id":"store"},"label":{"text":"append"}},
 {"type":"arrow","id":"s4","x":860,"y":60,"width":180,"height":140,"start":{"id":"chat"},"end":{"id":"reg"}},
 {"type":"arrow","id":"s5","x":700,"y":70,"width":-180,"height":140,"start":{"id":"chat"},"end":{"id":"rgw"}},
 {"type":"arrow","id":"s6","x":340,"y":215,"width":-160,"height":0,"start":{"id":"rgw"},"end":{"id":"recipient"},"label":{"text":"push"}},
 {"type":"arrow","id":"s7","x":800,"y":70,"width":1,"height":290,"start":{"id":"chat"},"end":{"id":"push"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"s8","x":680,"y":395,"width":-160,"height":0,"start":{"id":"push"},"end":{"id":"apns"},"label":{"text":"notify"}},
 {"type":"arrow","id":"s9","x":400,"y":360,"width":-310,"height":-110,"start":{"id":"apns"},"end":{"id":"recipient"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"p1","x":430,"y":0,"width":1,"height":-130,"start":{"id":"gw"},"end":{"id":"pres"},"label":{"text":"connect"}},
 {"type":"arrow","id":"p2","x":520,"y":-165,"width":150,"height":0,"start":{"id":"pres"},"end":{"id":"pstore"}}
]}

4) A bus between services and gateways

Direct RPC from the chat service to the recipient's gateway is the fast path. Everything else that reacts to a message (push notifications, receipts to the sender, search indexing, abuse checks) reads a log partitioned by recipient, so a slow consumer never slows sending.

{"type":"excalidraw","elements":[
 {"type":"text","id":"legend","x":0,"y":-265,"text":"Shaded: added in this step","fontSize":16},
 {"type":"rectangle","id":"sender","x":0,"y":0,"width":180,"height":70,"label":{"text":"Sender app"}},
 {"type":"rectangle","id":"gw","x":340,"y":0,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"chat","x":680,"y":0,"width":180,"height":70,"label":{"text":"Chat service"}},
 {"type":"ellipse","id":"store","x":1020,"y":-5,"width":200,"height":80,"label":{"text":"Messages +\ninbox"}},
 {"type":"ellipse","id":"reg","x":1020,"y":175,"width":200,"height":80,"label":{"text":"Session\nregistry"}},
 {"type":"rectangle","id":"rgw","x":340,"y":180,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"recipient","x":0,"y":180,"width":180,"height":70,"label":{"text":"Recipient app"}},
 {"type":"rectangle","id":"log","x":1020,"y":360,"width":200,"height":70,"label":{"text":"Event log\n(Kafka)"},"backgroundColor":"#d0ebff","fillStyle":"solid"},
 {"type":"rectangle","id":"push","x":680,"y":360,"width":180,"height":70,"label":{"text":"Push service"}},
 {"type":"rectangle","id":"apns","x":340,"y":360,"width":180,"height":70,"label":{"text":"APNs / FCM"}},
 {"type":"rectangle","id":"pres","x":340,"y":-200,"width":180,"height":70,"label":{"text":"Presence\nservice"}},
 {"type":"ellipse","id":"pstore","x":670,"y":-205,"width":200,"height":80,"label":{"text":"Presence\n(Redis)"}},
 {"type":"arrow","id":"s1","x":180,"y":35,"width":160,"height":0,"start":{"id":"sender"},"end":{"id":"gw"},"label":{"text":"WebSocket"}},
 {"type":"arrow","id":"s2","x":520,"y":35,"width":160,"height":0,"start":{"id":"gw"},"end":{"id":"chat"},"label":{"text":"send"}},
 {"type":"arrow","id":"s3","x":860,"y":35,"width":160,"height":0,"start":{"id":"chat"},"end":{"id":"store"},"label":{"text":"append"}},
 {"type":"arrow","id":"s4","x":860,"y":60,"width":180,"height":140,"start":{"id":"chat"},"end":{"id":"reg"}},
 {"type":"arrow","id":"s5","x":700,"y":70,"width":-180,"height":140,"start":{"id":"chat"},"end":{"id":"rgw"}},
 {"type":"arrow","id":"s6","x":340,"y":215,"width":-160,"height":0,"start":{"id":"rgw"},"end":{"id":"recipient"},"label":{"text":"push"}},
 {"type":"arrow","id":"s7","x":840,"y":70,"width":220,"height":290,"start":{"id":"chat"},"end":{"id":"log"},"strokeStyle":"dashed","label":{"text":"events"}},
 {"type":"arrow","id":"s10","x":1020,"y":395,"width":-160,"height":0,"start":{"id":"log"},"end":{"id":"push"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"s8","x":680,"y":395,"width":-160,"height":0,"start":{"id":"push"},"end":{"id":"apns"},"label":{"text":"notify"}},
 {"type":"arrow","id":"s9","x":400,"y":360,"width":-310,"height":-110,"start":{"id":"apns"},"end":{"id":"recipient"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"p1","x":430,"y":0,"width":1,"height":-130,"start":{"id":"gw"},"end":{"id":"pres"},"label":{"text":"connect"}},
 {"type":"arrow","id":"p2","x":520,"y":-165,"width":150,"height":0,"start":{"id":"pres"},"end":{"id":"pstore"}},
 {"type":"text","id":"note","x":0,"y":480,"text":"Solid: synchronous send path. Dashed: asynchronous events.","fontSize":16}
]}

Final design

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"sender","x":0,"y":0,"width":180,"height":70,"label":{"text":"Sender app"}},
 {"type":"rectangle","id":"gw","x":340,"y":0,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"chat","x":680,"y":0,"width":180,"height":70,"label":{"text":"Chat service"}},
 {"type":"ellipse","id":"store","x":1020,"y":-5,"width":200,"height":80,"label":{"text":"Messages +\ninbox"}},
 {"type":"ellipse","id":"reg","x":1020,"y":175,"width":200,"height":80,"label":{"text":"Session\nregistry"}},
 {"type":"rectangle","id":"rgw","x":340,"y":180,"width":180,"height":70,"label":{"text":"Gateway"}},
 {"type":"rectangle","id":"recipient","x":0,"y":180,"width":180,"height":70,"label":{"text":"Recipient app"}},
 {"type":"rectangle","id":"log","x":1020,"y":360,"width":200,"height":70,"label":{"text":"Event log\n(Kafka)"}},
 {"type":"rectangle","id":"push","x":680,"y":360,"width":180,"height":70,"label":{"text":"Push service"}},
 {"type":"rectangle","id":"apns","x":340,"y":360,"width":180,"height":70,"label":{"text":"APNs / FCM"}},
 {"type":"rectangle","id":"pres","x":340,"y":-200,"width":180,"height":70,"label":{"text":"Presence\nservice"}},
 {"type":"ellipse","id":"pstore","x":670,"y":-205,"width":200,"height":80,"label":{"text":"Presence\n(Redis)"}},
 {"type":"arrow","id":"s1","x":180,"y":35,"width":160,"height":0,"start":{"id":"sender"},"end":{"id":"gw"},"label":{"text":"WebSocket"}},
 {"type":"arrow","id":"s2","x":520,"y":35,"width":160,"height":0,"start":{"id":"gw"},"end":{"id":"chat"},"label":{"text":"send"}},
 {"type":"arrow","id":"s3","x":860,"y":35,"width":160,"height":0,"start":{"id":"chat"},"end":{"id":"store"},"label":{"text":"append"}},
 {"type":"arrow","id":"s4","x":860,"y":60,"width":180,"height":140,"start":{"id":"chat"},"end":{"id":"reg"}},
 {"type":"arrow","id":"s5","x":700,"y":70,"width":-180,"height":140,"start":{"id":"chat"},"end":{"id":"rgw"}},
 {"type":"arrow","id":"s6","x":340,"y":215,"width":-160,"height":0,"start":{"id":"rgw"},"end":{"id":"recipient"},"label":{"text":"push"}},
 {"type":"arrow","id":"s7","x":840,"y":70,"width":220,"height":290,"start":{"id":"chat"},"end":{"id":"log"},"strokeStyle":"dashed","label":{"text":"events"}},
 {"type":"arrow","id":"s10","x":1020,"y":395,"width":-160,"height":0,"start":{"id":"log"},"end":{"id":"push"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"s8","x":680,"y":395,"width":-160,"height":0,"start":{"id":"push"},"end":{"id":"apns"},"label":{"text":"notify"}},
 {"type":"arrow","id":"s9","x":400,"y":360,"width":-310,"height":-110,"start":{"id":"apns"},"end":{"id":"recipient"},"strokeStyle":"dashed"},
 {"type":"arrow","id":"p1","x":430,"y":0,"width":1,"height":-130,"start":{"id":"gw"},"end":{"id":"pres"},"label":{"text":"connect"}},
 {"type":"arrow","id":"p2","x":520,"y":-165,"width":150,"height":0,"start":{"id":"pres"},"end":{"id":"pstore"}},
 {"type":"text","id":"note","x":0,"y":480,"text":"Solid: synchronous send path. Dashed: asynchronous events.","fontSize":16}
]}

Deep dives

Ordering and sequence numbers. Ordering only has to hold within a conversation. Route every send for a conversation to the chat-service instance that owns it (consistent hashing on conversation_id), which assigns seq from an in-memory counter recovered from the store's max seq on ownership change. A lighter alternative avoids an owner: use a lightweight transaction (compare-and-set on a per-conversation counter row), at ~2–4 ms extra per message. Client timestamps never order messages; clocks on phones are wrong.

Exactly-once from the user's point of view. The network gives at-least-once at best. The sender retries send with the same client_msg_id until it gets an ack; the chat service keeps (conversation_id, client_msg_id) → seq for 24 hours and returns the stored seq on a repeat. The recipient's device drops any message whose (conversation_id, seq) it already has. Together these give "each message shown once".

The order of writes on send. (1) Write the message and the inbox rows (one batch to the same store, quorum writes). (2) Ack the sender: now the message is durable, so "sent" is honest. (3) Push to the recipient's gateway if a session exists. (4) Emit the event. If step 3 fails, nothing is lost: the inbox has it and the next sync delivers it. That is why the inbox is the source of truth and the live push is only an optimization.

Reconnect and sync. On connect, the device sends sync {after_inbox_seq}; the server pages through the inbox (500 per page) and the device acks with delivered {inbox_seq} after storing each page. A user who was offline for a week with 3,000 messages gets them in six round trips. The gateway registers the session before the sync starts, so a message sent during the sync is either in a later page or pushed live; the device dedupes the overlap.

Choice of message bus. Three options for getting a message to the recipient's gateway:

Option Latency Durability Cost
Direct RPC via session registry ~1 ms in region none (inbox covers it) registry lookup per message
Redis pub/sub channel per gateway ~1 ms none simple, but a gateway that blinks loses messages in flight
Kafka partitioned by recipient 5–20 ms durable, replayable extra hop on the hot path

Direct RPC for the live path plus the durable inbox gives the lowest latency without giving up durability. Kafka carries the asynchronous fan-out (push, receipts, search, analytics), where a few milliseconds do not matter and replay does.

Presence without heartbeat storms. The WebSocket's own ping/pong (every 30 s, handled inside the gateway) detects dead connections; only changes leave the gateway. A disconnect is reported as "offline" after a 10–20 s grace period, so a phone switching from Wi-Fi to LTE does not flicker. Presence updates are sent only to clients subscribed to that user (open chat screen or visible contact list), which turns an O(contacts) fan-out per change into O(viewers). Contact lists fetch presence in a batch on open (MGET over 200 keys, ~1 ms).

Gateway failure. 450 gateways at 100k connections: losing one drops 100k connections, which reconnect through the load balancer with jittered backoff (spread over ~30 s, or the session registry and sync endpoints see a 100k-request spike at once). Session entries carry the connection_id, so a stale entry from the dead gateway is overwritten on reconnect, and pushes to a dead gateway fail fast and fall back to the inbox.

Trade-offs

  • Inbox fan-out on write doubles writes (message plus inbox row) but makes offline sync one query by cursor. Reading "all conversations since time t" without it would need a scan over every conversation the user is in.
  • Owner-assigned sequence numbers give clean ordering and cheap assignment but need ownership handover on failure; compare-and-set counters avoid that at a few milliseconds per message.
  • Approximate presence with a grace period avoids flicker and heartbeat load, at the cost of up to ~20 s of staleness.
  • Direct push without a durable bus is fastest, and correctness comes from the inbox, not from the bus.
  • Wide-column store scales writes linearly and suits append-and-range-read, but has no cross-partition transactions; the design never needs them.

Interviewer expectations

Mid-level (L4)

  • Draws persistent connections through gateways, a chat service, a message store and a session lookup.
  • Stores messages for offline users and sends a push notification.
  • Gives rough numbers for messages per second and connections.

Senior (L5)

  • Defines per-conversation sequence numbers and idempotent sends with client message ids, plus device-side dedupe.
  • Uses a per-user inbox with a cursor for sync, and orders writes so "sent" means durable.
  • Designs presence from connection events with a grace period and subscription-based fan-out; justifies the bus choice.

Staff (L6+)

  • Handles failure modes end to end: gateway loss and reconnect storms, ownership handover for sequence assignment, sync racing with live pushes.
  • Explains what changes for multi-device (per-device cursors), groups (fan-out on write versus read by group size) and end-to-end encryption (server stores ciphertext; key distribution per device).
  • Sizes the fleet and storage and identifies the first bottleneck at 10× scale.

Make it yours

Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups.