Worked problems · WhatsApp-style chat

Worked problems

WhatsApp-style chat

Deliver messages between people and small groups, with order, receipts, and a phone that was offline all afternoon.

3 min read system-design

What you are designing

Two people, or a small group, exchange messages. Recipients who are online see them immediately. Recipients who are not see them when they open the app. Order inside a conversation is stable. Assume a billion users, messages that are small, groups that you cap (for example a few hundred) unless you explicitly design channels, and that media is an attachment pointer.

Requirements

  • Send a message and know the server has it.
  • Deliver to all members, online via socket, offline via catch-up.
  • Show sent and delivered, and optionally read.
  • Do not drop or duplicate a message the client retried.

A one-second delay is fine. A lost message is not. Search across all history is out of scope unless you have time; say you would index asynchronously.

Entities and API

Conversation, Member, Message(conversation_id, seq, sender, body, client_id), DeviceCursor.

POST /v1/conversations/{id}/messages    { client_id, body }
GET  /v1/conversations/{id}/messages?after_seq=
WS   subscribe conversation or user inbox

Send returns 200 with the assigned seq only after the row is durable. The same client_id returns the original message, not a second row, which is how a flaky mobile radio stays safe. after_seq is how a reconnect catches up. An unknown conversation is 404. A conversation the caller is not in is 403. The socket is an optimization. A client that only polls the GET still receives every message, late.

High-level design

flowchart LR
  A[Sender] --> API
  API --> DB[(Messages by conversation)]
  API --> Bus[Inbox pub/sub]
  Bus --> G1[Gateway]
  G1 --> B[Online device]
  C[Offline device] -->|catch-up| API

Send inserts the next sequence and returns it. A pub/sub wakes gateways that hold sockets for the members. Each gateway writes the thin event to its local sockets. Devices that miss it pull after_seq on reconnect. See the real-time page for the gateway split.

Data model

CREATE TABLE messages (
  conversation_id BIGINT NOT NULL,
  seq             BIGINT NOT NULL,
  sender_id       BIGINT NOT NULL,
  client_id       TEXT NOT NULL,
  body            TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (conversation_id, seq),
  UNIQUE (conversation_id, sender_id, client_id)
);
CREATE TABLE device_cursors (
  device_id       BIGINT NOT NULL,
  conversation_id BIGINT NOT NULL,
  delivered_seq   BIGINT NOT NULL,
  PRIMARY KEY (device_id, conversation_id)
);

At scale this table is partitioned by conversation_id. A sequence allocator per conversation (a row you increment, or a monotonic id from a single writer for that conversation) gives you order without a global lock.

Deep dives

Offline and multi-device. Messages stay until every member device has passed them or until a retention window you state (a phone that is gone for a year may need a full refetch of recent history, not an infinite queue). The inbox for “which conversations changed?” is a per-user list of (conversation_id, latest_seq) so the app does not poll every thread.

Groups. Small groups copy the message once and notify N members. That N is the fan-out. Past a threshold, either store once and have clients pull (a channel) or fan out asynchronously with isolation so one giant group cannot block direct messages. Say the cap in the requirements.

Receipts. Delivered and read are events (conversation, member, seq), compacted to “this member has read up to seq.” They are not updates to the message body. They can be lossy and delayed. Do not put them on the send path.

Media and abuse. Attachments use the blob upload pattern. The message stores the object id. Block lists are checked at send and at fan-out. If you promise end-to-end encryption, the server stores ciphertext and you cannot do server-side search or content moderation without a separate client-side design. Do not claim E2E and then draw a server that ranks message text.

Levels

  • Mid-level. Durable message, sequence, online push, offline pull, client id dedup.
  • Senior. Gateway separation, per-device cursors, group cap, receipts off the hot path.
  • Staff. Partitioning, hot conversations, encryption boundary, and what you do when the push vendor is down (the cursor still works).

Further reading

Check yourself

Answer the questions. Multiple choice is scored on this device only.

1. When do you ack the sender?

2. A user has two phones. Cursors should be:

3. How do you handle a retry of the same send?