Design WhatsApp

Design a WhatsApp-like messenger step by step: 1:1 and group chat, sequence numbers, WebSockets, offline resume, receipts, hybrid fan-out, and the reasoning behind every major decision.

Page content

Alice sends Bob a text. Bob’s phone is in his pocket, but his laptop is open. A second later Alice’s network retries the same send. Then Bob boards a flight. Alice sends three more messages. When Bob lands, his phone should show every message, in order, without duplicates, and without Alice having waited on the airplane.

That is the product. Chat is not a news feed. There is no “merge 200 timelines.” There is a conversation, a sequence, devices that come and go, and a group that might have eight people or a million.

The main design question is:

How do we persist a message once, deliver it in order to every online device, and let offline devices catch up, without write-amplifying huge groups into a bottleneck?

We will start with one conversation and a database. WebSockets, Kafka, and a connection registry appear only after a simpler path fails.

1. Clarify the problem

“Design WhatsApp” includes calls, Status, payments, and end-to-end encryption. That is too broad for one interview.

I would ask:

  • 1:1 only, or groups as well?
  • Must messages appear live, or is refresh enough?
  • Do we keep server-side history, or is the device the only store?
  • Multi-device: phone plus laptop plus tablet?
  • Delivery and read receipts?
  • How large can a group be?
  • Media: images and files, or text only?
  • What traffic and retention?

If the interviewer gives no extra constraints, I would state:

Product              1:1 and group chat with server-side history
Not in scope         Calls, Status, payments, E2E crypto internals
Delivery             Real-time when online; durable when offline
Devices              Multiple concurrent connections per user
Receipts             Sent, delivered, read
Groups               Small groups common; some very large
Media                Upload via object storage, not through chat servers
Auth                 Assumed; membership checks in the backend
Deployment           One region first

Real WhatsApp encrypts so the server cannot read message bodies. This interview design keeps plaintext (or opaque blobs) on the server because that is how we teach history, catch-up, and search-shaped access. I would say that out loud. The fan-out, sequencing, and connection problems do not disappear if bodies are ciphertext.

2. Functional requirements

The system must:

  1. Send and receive 1:1 messages.
  2. Create groups, add and remove members, send group messages.
  3. Deliver to online devices in near real time.
  4. Persist messages so offline users catch up.
  5. Show history with cursor pagination.
  6. Record sent / delivered / read.
  7. Show a simple online/offline presence.
  8. Accept media as a reference to object storage.
  9. Treat client retries as one message.

The first version does not include:

  • voice or video calls;
  • Status or stories;
  • payments;
  • implementing Signal-style E2E;
  • transcoding pipelines; or
  • a full identity provider.

3. Non-functional requirements

RequirementTarget
Online deliveryp99 of a few hundred ms in-region after persist
Send ACKp99 below 200 ms for the durable accept
OrderingMonotonic sequence per conversation, not global
DurabilityAn acknowledged send is never lost
Multi-deviceAll of Bob’s open sockets can receive the message
OfflineCatch up from afterSequence
AvailabilityChat send remains possible if push or presence is down
ScaleSkew from huge groups must not stall 1:1 chat

Consistency is not one global setting.

Persisted message + sequence     source of truth
Online push over WebSocket       at-least-once, may duplicate
Presence                         ephemeral; may be wrong for a few seconds
Push notification                best-effort
Read watermark                   per user per conversation; may lag

Edge cases to keep in mind

  • Alice’s client retries with the same clientMessageId.
  • Two of Alice’s devices send into the same chat at once.
  • Bob has iPhone, Mac, and iPad connected.
  • Bob is offline; the message must still exist.
  • Sequence 1001 then 1003 arrives; 1002 is missing.
  • A connection server dies with 200,000 sockets.
  • The same MessageCreated event is consumed twice.
  • A group has 8 members; another has 1,000,000.
  • Only admins may add members.
  • Alice is removed from a group and must not keep receiving.

4. Estimate the scale

Use round numbers. We need the bottleneck, not a prospectus.

Assume a large messenger:

Daily active users              200 million
Messages per user per day       40
Messages per day                8 billion
Average send QPS                8e9 / 86,400 ≈ 93,000
Peak (10×)                      ~1 million messages/s
Average payload                 500 bytes text metadata + body
Media                           separate; not through the chat hot path

Fan-out, not raw ingest, is the danger. If the average chat has 2 recipients including the sender’s other devices, durable writes stay near ingest. If we copy every group message into each member’s inbox:

1 message to a 1,000,000-member group
  → 1,000,000 inbox writes

That is the celebrity-tweet problem in chat clothing. 1:1 traffic must not sit behind that job.

Storage for text history:

8 billion × 1 KB ≈ 8 TB/day

Keep recent chats hot; age into cheaper storage; put media in object storage. Do not put video bytes in the message table.

5. What are we storing?

A conversation is an ordered log. A user is a member of many logs. A device is a connection to those logs.

User                  Alice, Bob
Conversation          DIRECT or GROUP
Member                user in a conversation, with a role
Message               one entry in that log, with a sequence
Receipt watermark     last delivered / last read sequence per user
Connection            ephemeral: user → sockets
Source of truth     messages, membership, conversation metadata
Derived data        per-user inbox pointers for small-group fan-out
Cache / ephemeral   presence, connection registry, hot idempotency
Event system        outbox → Kafka for delivery, push, analytics
Blob store          images, video, files

Presence is not a message store. If Redis forgets that Bob is online, the next socket heartbeat or reconnect fixes it. If Redis is the only copy of PAY-101… that is the wrong system. Messages do not live only in Redis.

6. APIs

HTTP for commands and history. WebSocket for the live stream. Sending can be HTTP or a WebSocket frame; both must hit the same idempotent persist path.

Send message

POST /v1/conversations/c_ab/messages
Authorization: Bearer <token>

{
  "client_message_id": "alice-phone-9f3a",
  "type": "TEXT",
  "content": "landed, coming home"
}
HTTP 201 Created

{
  "message_id": "m_1001",
  "conversation_id": "c_ab",
  "sender_id": "u_alice",
  "client_message_id": "alice-phone-9f3a",
  "sequence_number": 1001,
  "created_at": "2026-08-17T18:01:00Z"
}

The 201 means the log accepted the entry. It does not mean Bob’s phone has it.

A retry with the same client_message_id from Alice returns the original m_1001 and sequence 1001.

History and catch-up

GET /v1/conversations/c_ab/messages?after_sequence=1000&limit=50
GET /v1/conversations/c_ab/messages?before_sequence=1000&limit=50
HTTP 200 OK

{
  "messages": [
    { "message_id": "m_1001", "sequence_number": 1001, "content": "landed, coming home" }
  ],
  "next_after_sequence": 1001
}

after_sequence is how Bob resumes after a flight. before_sequence is how he scrolls up. Neither is OFFSET 50000.

Groups, receipts, presence

POST /v1/conversations                         { "type": "GROUP", "member_ids": [...] }
POST /v1/conversations/{id}/members
DELETE /v1/conversations/{id}/members/{userId}

POST /v1/conversations/{id}/delivered          { "up_to_sequence": 1001 }
POST /v1/conversations/{id}/read               { "up_to_sequence": 1001 }

GET  /v1/users/{id}/presence
POST /v1/media/upload-url

Delivered and read are watermarks, not one row per message per user in the group case.

Errors

400  VALIDATION_ERROR
401  UNAUTHENTICATED
403  NOT_A_MEMBER, NOT_ADMIN
404  CONVERSATION_NOT_FOUND
409  IDEMPOTENCY_CONFLICT

7. Basic data model

Metadata (relational)

users
  id, name, created_at

conversations
  id, type DIRECT|GROUP, created_by, created_at

conversation_members
  conversation_id, user_id, role ADMIN|MEMBER, joined_at
  unique (conversation_id, user_id)

user_conversations                 optional reverse index
  user_id, conversation_id, last_message_at, last_read_sequence

Direct chats: unique pair (min(user_a), max(user_b)) so Alice/Bob is one conversation.

Messages (wide-column / partitioned log)

Primary access: this conversation, in sequence order.

messages
  PK / partition     conversation_id  (+ time bucket if the chat is huge)
  sort               sequence_number
  message_id
  sender_id
  client_message_id
  type               TEXT|IMAGE|VIDEO|FILE
  content            text or media key
  created_at
UNIQUE (sender_id, client_message_id)
UNIQUE (conversation_id, sequence_number)

A production message store is often Cassandra, DynamoDB, or Scylla because the access pattern is a partition key plus an ordered sort key, and write throughput is high. PostgreSQL can start the interview and hold a surprising amount. The model is still a per-conversation log. I would not put all of Earth’s chats in one unpartitioned SQL table.

Idempotency and watermarks

idempotency:           (sender_id, client_message_id) → message_id, sequence
read_watermark:        (conversation_id, user_id) → last_read_sequence
delivered_watermark:   (conversation_id, user_id) → last_delivered_sequence

For groups, watermarks beat message_receipts of size messages × members.

8. Polling is the wrong first live path

The simplest design:

POST message → INSERT
Bob's app GET every 2 seconds

It works for a demo. It doubles load with polling, adds seconds of lag, and still needs the same persist logic. Use it only to get the log right, then replace the pull of new messages with a push. History queries stay HTTP.

9. Persist first, then try to push

If we push to Bob’s socket and write the database after:

Alice gets ACK
socket write succeeds
database write fails
Bob saw a message that does not exist

Or the opposite: we ACK Alice after the socket write, Bob’s phone was offline, the message is gone.

The durable accept is the database (plus outbox). Real-time delivery is an optimization on top.

Alice
Validate membership
Assign sequence, insert message + outbox     ← ACK here
Deliver to online sockets / push

If Bob is offline, the row is enough. His next after_sequence fetch or reconnect catch-up loads it. Push notification is for “look at your phone,” not for durability.

10. Sequence numbers are the conversation clock

Messages in c_ab must be totally ordered. Messages in c_group have their own clock. There is no need for a global sequence across WhatsApp.

c_ab      1001, 1002, 1003
c_family  44, 45

Sequence does four jobs:

order          1001 before 1002
pagination     after_sequence=1000
resume         Bob stored 1000 locally
gap detect     saw 1001 and 1003 → fetch 1002

Kafka ordering is the same idea: order exists inside a partition. Partition by conversation_id. Do not promise a global timeline of all messages on earth.

Clients must tolerate at-least-once push: same message_id twice. Sequence tells them what to ignore and what is missing.

11. Do not use MAX(sequence)+1

Two of Alice’s devices send at once:

Device A reads MAX=1000
Device B reads MAX=1000
Both insert 1001

The unique constraint saves you from silent corruption and turns it into an error. That is not a sequencer.

Production options, simplest first:

1. Atomic counter per conversation
   UPDATE conversation_seqs SET n = n + 1 WHERE id = c_ab RETURNING n

2. Conversation row locked for the insert
   SELECT n FROM conversation_seqs WHERE id = c_ab FOR UPDATE

3. Partition-local allocator
   Each conversation lives on one message-DB partition that issues numbers

4. Do not use Kafka offsets as the user-visible sequence
   Offsets change with compaction, replay, and dual publishers

For the interview, (1) or (2) in the same transaction as the insert is the correct local answer. The lock is per conversation, so Alice/Bob does not block Charlie’s group.

Gaps are allowed if a transaction assigns 1004 and then aborts. Duplicates are not. Clients already handle gaps: they request the missing sequence.

12. Idempotent send

Alice’s phone generates client_message_id before the request. The key is:

(sender_id, client_message_id)

not client_message_id alone. Otherwise two users could collide on "1".

BEGIN
  if exists (sender, client_id): return stored message
  next = increment sequence
  insert message
  insert outbox MessageCreated
COMMIT

The unique index is the real lock across two API pods. Returning the stored row makes retries safe. If the same key arrives with different content, return 409 IDEMPOTENCY_CONFLICT.

This is the same idea as invoice idempotency: the client names the write.

13. WebSockets carry live bytes, not truth

When Bob’s laptop connects:

Bob → TLS → load balancer → connection server CS3
CS3 registers in Redis:  u_bob → {cs3, conn_laptop}
presence u_bob = ONLINE

Bob’s phone is already on CS1. The registry is a set:

u_bob
  ├── CS1 / iPhone
  ├── CS3 / Mac
  └── (later) CS7 / iPad

A message for Bob is published to every connection. The phone and the laptop both show it. If we stored only one socket per user, the laptop would starve.

When a socket dies:

unregister that connection
if no connections left: presence OFFLINE

Connection servers are sticky enough that a socket stays on one box, but the registry must be global so CS1 can be told to write Bob’s phone when the message was accepted on another pod.

Why WebSockets rather than polling or SSE:

Polling     lag and load
SSE         server → client only; receipts need another channel
WebSocket   bidirectional: send, ack, delivered, typing (if added)

WebSockets do not replace history HTTP. They are pipes. After persist, a delivery worker looks up sockets and writes frames. If the write fails, the message is still in the log.

Reconnect is not “open a socket and hope.” It is subscribe, then catch up:

1. Connect WebSocket, authenticate
2. Register connection
3. For each open conversation (or the active one):
     GET messages?after_sequence=<local watermark>
4. Then apply live frames with sequence > watermark
5. Deduplicate by message_id

That is the same subscribe-then-snapshot idea as live order tracking: the race between “go live” and “load history” must not drop 1002.

14. Presence is a lease

ONLINE / OFFLINE is a cache of “we currently have a socket.” Store it in Redis with a TTL refreshed by heartbeats. If CS3 burns down, TTLs expire and Bob looks offline until his apps reconnect. That is acceptable.

Do not join presence into the message transaction. Do not block send on Redis. If presence is wrong, push notifications still fire for users with no live sockets.

15. Offline delivery is the log plus a cursor

Bob’s phone stored last_received_sequence = 1000 for c_ab. While he was in the air, Alice wrote 1001–1003.

GET /conversations/c_ab/messages?after_sequence=1000&limit=50

returns 1001, 1002, 1003. No inbox table is required for 1:1. The conversation log is the inbox.

Push (FCM/APNs) is: “you have new messages.” The app then catch-up-fetches. If push is down, Bob opens the app later and catch-up still works.

If a live frame arrives out of order:

1001
1003   ← gap

the client requests after_sequence=1001&limit=… or a fetch of 1002. Do not reorder by wall clock. created_at can skew across devices; sequence is the order we assigned at persist.

16. Receipts: watermarks, not a row per eyeball

SENT        the log accepted it (sequence assigned)
DELIVERED   a recipient device got it (or catch-up applied it)
READ        the recipient opened the conversation up to that sequence

For 1:1, a couple of watermark rows are enough:

Bob delivered_up_to = 1003
Bob read_up_to      = 1001

Alice’s UI: ticks on messages <= 1003 delivered, <= 1001 read.

For a 50,000-member group, writing DELIVERED per member per message is a write storm. Store:

(group_id, user_id) → last_read_sequence

“Who has read message 1001?” is last_read_sequence >= 1001. Do not do that query on the send path. Group read receipts, if shown at all, are sampled or omitted for huge groups. WhatsApp itself is conservative here; so should you be.

Updating a watermark is idempotent: last_read = GREATEST(old, new).

17. Groups are membership plus the same log

Create group     conversation type=GROUP, creator ADMIN
Add member       only ADMIN
Remove member    only ADMIN
Send             sender must be a current member

Check membership in the persist transaction (or with a fencing membership version). Otherwise Alice is removed and her in-flight send still lands.

Each group is still one sequence. Fan-out is how devices hear about the new sequence, not how we store five million copies of the body.

18. Fan-out on write vs fan-out on read

Small group, eight members:

Persist once in the group log
Notify 8 users' connection sets
Update 8 user-inbox pointers  (optional, for “chat list” recency)

That is fan-out on write of pointers and socket frames, not of the message body.

Huge group, 1,000,000 members:

Persist once
Do not write 1,000,000 inbox rows
Online members receive if they are subscribed to this conversation
Everyone else sees it on open: GET after_sequence
Push only to recently active members, or skip push

That is fan-out on read, the same trade-off as celebrity tweets. Hybrid:

members < N   (e.g. 500)     write-time notify all
members ≥ N                  read-time / subscribe-time

Do not implement a million inserts in a local demo. Draw the threshold and say why 1:1 traffic has a separate lane so a mega-group cannot stall Alice→Bob.

19. Kafka is the delivery bus, not the chat log

After persist:

Message DB transaction
  insert message
  insert outbox MessageCreated
COMMIT

Outbox publisher → Kafka
  key = conversation_id

Consumers:

Delivery / fan-out     look up sockets, write frames
Notification           FCM/APNs if no sockets
Chat-list recency      optional pointer updates

Why Kafka:

  • ingest is decoupled from slow push providers;
  • lag is a metric, not a lost message (the DB still has it);
  • order within conversation_id so fan-out does not apply 1003 before 1002 to a given consumer.

Kafka does not replace the message database. Offsets are not sequence numbers. If Kafka is down, the outbox waits; Alice already got 201. Online delivery lags; catch-up HTTP still works.

Duplicate MessageCreated is normal. Delivery consumers are idempotent: sending the same frame twice is fine; clients dedupe message_id. Push consumers use (provider, message_id, user_id) uniqueness so Bob is not spammed.

20. Media does not enter the chat process

Client → POST /media/upload-url
       ← pre-signed PUT
Client → PUT bytes to object storage
Client → POST message { type: IMAGE, object_key, size, checksum }

Chat servers never see 12 MB. They store metadata. Download uses a short-lived GET URL after membership is checked. The pattern matches invoices: blobs in object storage, authorization in the API.

21. Cursor pagination

A chat log grows at the front. OFFSET 50 means “skip 50 rows that might have changed” and gets slower as history deepens.

WHERE conversation_id = c_ab
  AND sequence_number > $after
ORDER BY sequence_number
LIMIT 50

or sequence_number < $before for scroll-up. The cursor is the sequence. It stays valid because we never reuse a sequence.

22. Caching and Redis

Redis is the right home for:

connection registry     user → [{server, conn}]
presence                user → ONLINE + TTL
hot membership          small groups
optional idempotency    with DB unique as source of truth

Redis is the wrong home for the message log. A failover must not erase Alice’s send. Document that in the interview; it is a common trap.

23. Failure scenarios

FailureBehavior
Message DB downSend fails; do not fake an ACK
Redis downSend still persists; live push degrades; presence unknown
Kafka downOutbox waits; catch-up HTTP works
Connection server diesSockets drop; clients reconnect; catch up from sequence
Duplicate sendSame client_message_id → one row
Duplicate eventIdempotent consumers; client dedupes
Missing sequenceClient fetches the gap
Push provider downIn-app catch-up still works
User not a member403; no persist

A connection-server loss looks like a mass offline event. That is noisy for presence, harmless for durability. Capacity-plan reconnect storms (jittered reconnect).

24. Observability, security, scale

Log request_id, message_id, conversation_id, sender_id, sequence, client_message_id, connect/disconnect.

Metrics that matter:

messages accepted/s
persist latency
socket frames/s
Kafka consumer lag
catch-up query latency
push failures
connections per connection-server

Authorize every send and history read with membership. Do not leak group messages to removed users. Rate-limit send per user. Multi-region: a conversation has a home region (or the two 1:1 users share one); global sequence across regions is a consensus problem you should not volunteer. Cross-region catch-up is eventual; in-region persist is the ACK.

Scale connection servers by user socket count (hundreds of thousands of idle sockets per box is normal). Scale message writes by conversation_id partition. Isolate mega-group fan-out workers from 1:1 delivery.

25. Where logic lives

cmd/server
internal/conversation     membership, roles
internal/message          persist, sequence, idempotency
internal/outbox
internal/delivery         Kafka consumer → connection registry
internal/websocket        connection servers
internal/presence
internal/notification     FCM/APNs abstraction
internal/storage          upload/download URLs

One modular monolith can run locally with in-memory registry, fake push, and filesystem blobs. Production swaps in Kafka, Redis, Cassandra, S3, FCM. The send transaction does not change.

Handlers do not allocate sequences. The message service does, in one transaction with the row and outbox.

26. Final architecture

 Clients
    │  HTTP (send, history, receipts)
    │  WebSocket (live frames)
 Load balancer
    ├── Connection servers     Redis registry + presence
    └── Message / conversation API
         Message DB + outbox     (partition by conversation)
         Outbox publisher
         Kafka  key=conversation_id
            ├── Delivery: sockets for members
            └── Push: users with no sockets

 Object storage ← media PUT/GET (pre-signed)

Send path:

Alice POST
  → member check
  → increment sequence
  → insert message + outbox
  → 201 { message_id, sequence }

Online Bob:

Kafka → lookup u_bob connections → frame to phone and laptop

Offline Bob:

row waits in c_ab
reconnect → GET after_sequence

Huge group:

one persist
no million inbox writes
subscribers get frames; others catch up on open

27. Interview-ready summary

Key decisions to remember

  1. Scope to 1:1, groups, live delivery, history, receipts, presence — not calls or E2E internals.
  2. Persist and assign sequence before ACK; sockets are optional.
  3. Sequence is per conversation; Kafka key is conversation_id.
  4. Allocate sequence atomically; never MAX+1.
  5. Idempotency is (sender_id, client_message_id).
  6. Multi-device means a set of connections, not one socket per user.
  7. Offline catch-up is after_sequence, not a second message store.
  8. Group receipts are watermarks, not per-message rows.
  9. Hybrid fan-out: notify small groups eagerly; huge groups are a log.
  10. Redis is ephemeral; the message DB is truth; media is object storage.
  11. At-least-once + idempotent clients/consumers; not distributed exactly-once.
  12. Cursor pagination on sequence, never large OFFSET.

A 10–15 minute interview walkthrough

Use this pacing. Stop if they grab a thread.

Minutes 0–2 — requirements. 1:1 and groups, live plus offline, multi-device, receipts, media by reference. No calls, no E2E implementation. State server-side history as an explicit assumption.

Minutes 2–4 — APIs and model. Send with client_message_id, history with after_sequence / before_sequence, membership, watermarks. Conversation log: partition conversation_id, sort sequence_number.

Minutes 4–7 — send path. Membership → atomic sequence → message + outbox → 201. Then Kafka fan-out to sockets and push. ACK is durability, not “Bob saw it.”

Minutes 7–10 — devices, offline, order. Registry is user → many connections. Reconnect catch-up from last sequence. Gaps trigger a fetch. Dedupe by message_id. Presence is a Redis lease.

Minutes 10–13 — groups and scale. Small groups: persist once, notify members. Huge groups: persist once, fan-out on read. Isolate that work. Estimate ingest QPS and why inbox-per-member writes die.

Minutes 13–15 — failures and trade-offs. Duplicate send, dead connection server, Kafka down, push down. Outbox waits. Catch-up still works. Trade-off: E2E would change what the server stores; exactly-once is not worth it.

Strongest Senior-level talking points

  1. ACK means persisted, not delivered. Confusing those two is a junior tell.
  2. Per-conversation sequence is the clock for order, pagination, resume, and gaps.
  3. (sender_id, client_message_id) plus a unique constraint is real idempotency.
  4. Atomic sequence allocation, with a documented rejection of MAX+1.
  5. Multi-device as a set of sockets, all receiving the same message id.
  6. Subscribe then catch-up so reconnect does not drop a sequence.
  7. Watermarks for group reads instead of receipts × members × messages.
  8. Hybrid group fan-out, same instinct as celebrity tweets, applied to chats.
  9. Kafka partition = conversation, and Kafka is not the source of truth.
  10. Redis is not the message DB. Presence and connections may vanish; chats may not.

Likely interviewer follow-up questions

  • Why not store messages only in Redis for speed?
  • How do two devices avoid duplicate sequences?
  • What happens if the WebSocket ACK is lost but persist succeeded?
  • How does Bob get messages after eight hours offline?
  • How do you show read receipts in a 10,000-person group?
  • Why partition Kafka by conversation rather than by sender?
  • How is this different from Twitter fan-out?
  • Where would E2E encryption change the design?
  • How do you stop a mega-group from blocking 1:1 sends?
  • What does the client do when it sees sequence 1001 then 1003?

A 1–2 minute verbal answer

I would design a WhatsApp-like messenger with server-side history: 1:1 and groups, live delivery, offline catch-up, multi-device, and receipts. At 200 million DAU and 40 messages per user, ingest is on the order of 100,000 messages per second average and about a million at peak. The bottleneck is group fan-out, not the single insert.

Each conversation is an ordered log. Send checks membership, allocates a monotonic sequence with an atomic counter, inserts the message and an outbox row, then ACKs. (sender_id, client_message_id) makes retries one message. Kafka is keyed by conversation_id so delivery stays ordered per chat. Connection servers register every device socket in Redis; fan-out writes the same message to all of Bob’s connections. If he is offline, the log is the source of truth and he resumes with after_sequence.

Small groups get eager notify. Huge groups persist once and are read on open, so we do not write a million inbox rows. Receipts are per-user watermarks. Media uploads go to object storage; the message stores a key. Redis holds presence and sockets only. At-least-once delivery plus client dedupe is the semantic; we do not chase distributed exactly-once.

For the broader interview framework around this problem, see the System Design Interview Complete Guide.