Designing a Chat System

A system design interview walkthrough for building a real-time chat service like WhatsApp or Messenger: the connection protocol that carries messages, the split between stateless and stateful services, how chat history is stored and ordered, and how messages reach a recipient whether they are online or away.

Student edition · start with the guarantee

Deliver and synchronize messages across intermittent devices without confusing message identity with committed delivery order.

Correctness contract: A durably accepted logical message is replayable within retention; each device advances only a contiguous committed cursor, and duplicate sends share one result.

Basic HTTP, databases, and programming are assumed. Specialized terms are explained below. Named products motivate the problem; this is an educational design, not a claim about their current internals. Capacity numbers are illustrative decimal payload estimates, not benchmarks. Add indexes, protocol overhead, replicas, headroom, and failure capacity before provisioning.

A chat system looks deceptively simple from the outside — type a message, it shows up on someone else's screen — but it pushes on a specific set of design problems. The server has to push data to a client at an arbitrary moment, not just respond when asked. Messages must be durable so history survives a restart, ordered so a conversation reads correctly, and delivered fast when both parties are connected. And the same conversation may need to reach a recipient who is offline, on three devices, or part of a 500-person group. This guide builds the system up one decision at a time, in the order an interview tends to follow.

Contents

  1. Requirements
  2. Back-of-the-Envelope Estimation
  3. API Design
  4. Data Model
  5. Connection Protocols
  6. Service Decomposition
  7. Storage and Message IDs
  8. Service Discovery
  9. The 1:1 Message Flow
  10. Delivery Guarantees and Receipts
  11. Online Presence
  12. Group Chat
  13. Multi-Device Sync
  14. Failure Modes
  15. Tradeoffs & Alternatives
  16. Summary
  17. 45-Minute Interview Walkthrough
  18. Common Mistakes
  19. Check Yourself
  20. Key Terms & Further Reading
  21. Concepts to understand first
  22. Worked example
  23. Runnable Python model

Concepts to understand first

Each definition includes a concrete example and the condition under which the mechanism is useful. You can read this guide without opening another guide.

Sequence numbers and replay cursors

A sequence number records committed order within a stream. A cursor is a consumer's durable position in that stream, so it can resume without skipping missing events. An opaque identity is not automatically such a position.

Example. A device receives message sequence 10 then 12. It acknowledges contiguous 10, buffers 12, and fetches 11 before advancing to 12.

Limit. A Snowflake timestamp can arrive late behind an already-acknowledged cursor. Use per-stream commit order or a durable inbox offset, define retention gaps, and use separate device cursors.

Idempotency keys

A client-generated identity for one logical operation. The server binds it to caller scope, endpoint, request fingerprint, state, and result. A retry uses the same key and reuses the original outcome rather than repeating the effect.

Example. Payment p42 times out after the provider charged it. Retrying with p42 lets the provider return the same charge instead of creating a new one.

Limit. A unique local claim is insufficient if the business effect and saved result can diverge. Keep unknown outcomes pending, use downstream idempotency, and state the retention/retry horizon.

Fanout on write and read

Fanout distributes one event to many recipients. On-write fanout precomputes recipient inbox entries; on-read fanout merges source events when a recipient asks. A hybrid chooses using measured write amplification and read cost.

Example. One post to 200 followers creates up to 200 inbox inserts. A celebrity's million followers can instead read the author's shared stream when they open their feed.

Limit. Fanout is not atomic across inboxes. Persist the post and delivery work via an outbox, dedup references, catch up after misses, and recheck privacy/deletion at serving time.

Presence and failure detection

Presence is an estimate of recently observed connection activity. Heartbeats refresh session expiry, and a timeout suspects that a session is no longer live. A user may have several sessions/devices.

Example. A phone disconnects while its laptop keeps sending heartbeats. The user remains online if the product defines online as any active session.

Limit. Timeouts cannot distinguish a dead process from a paused or partitioned one. Counts should say sessions, users, or recently active viewers; privacy and stale snapshots need explicit policy.

Transactional outbox

A business transaction writes its state change and an event row to the same database. A relay publishes committed event rows later, retrying until the broker durably accepts them.

Example. An order and its OrderCreated outbox row commit together. A relay crash after publish but before marking sent publishes the same event ID again on restart.

Limit. The relay is at-least-once, not magically exactly-once. Consumers dedup transactionally; preserve per-entity sequence when order matters. CDC is change data capture: reading committed database changes.

Pub/sub and durable queues

Publish/subscribe routes an event to interested subscribers; it may be ephemeral or durable. A queue/log with retained records and offsets allows replay after a consumer is disconnected.

Example. An online gateway misses a comment while restarting. Ephemeral Redis Pub/Sub cannot replay it; a durable comment log can fetch after the gateway's last sequence.

Limit. Pub/sub alone does not promise persistence, global order, or exactly-once effects. Broker choice, acknowledgements, retry horizon, and client catch-up protocol define those guarantees.

1. Requirements

Before drawing boxes, pin down what the system must actually do. A focused feature list keeps the design honest and gives the interview a clear scope.

RequirementWhat it means
1:1 chatTwo users exchange text messages in near real time, with low end-to-end latency when both are connected.
Group chatA message sent to a group is delivered to every member. Group sizes are bounded (say, up to a few hundred) to keep fanout tractable.
Online presenceUsers can see whether their contacts are online, away, or last seen at some time.
Push when offlineIf a recipient is not connected, the message is still delivered later, and a push notification alerts them on their device.
Message historyMessages are persisted durably and can be re-read across sessions and devices, ordered by time.

The non-functional shape matters just as much. Chat is write-heavy (every message is a write, and group messages multiply that), latency-sensitive on the delivery path, and must keep a persistent connection open per active client. History grows without bound, so storage has to scale horizontally and read access is dominated by recency — people scroll the most recent messages far more often than ancient ones.

A good early framing question: is this a small-group messenger or a large broadcast system? The answer caps group size and fanout strategy. This guide assumes bounded groups, where per-member fanout is acceptable.
Clarifying questions to ask

1:1 only, or groups too, and how big? Text only, or images and video? Which platforms (mobile, web)? How many daily users? Do we keep history forever? Do we need read receipts, typing indicators, end-to-end encryption? Assume: 1:1 and groups up to 500, text first, 500M DAU, history forever, sent/delivered/read ticks.

Non-functional goalTarget
Delivery latency (both online)Under ~200 ms end to end, same region
DurabilityAcknowledged messages are durable under the documented replica/failure/retention contract, and recoverable through the accepted history.
Delivery guaranteeAt least once on the wire, exactly once on screen (dedup by message id)
OrderingSame order for every member of a conversation
AvailabilitySending keeps working when some servers fail

2. Back-of-the-Envelope Estimation

QuantityAssumptionResult
Messages per day500M DAU × 40 messages20 B/day
Average write rate20B ÷ 86,400 s~231K msg/s
Peak write rate×2 for evening peaks~463K msg/s
Storage per day20B × ~200 bytes (text + metadata)~4 TB/day
Storage per year4 TB × 365 (×3 replicas ≈ 4.4 PB)~1.46 PB/yr
Concurrent connections30% of DAU online at peak~150M sockets
Chat servers150M ÷ 100K sockets per server~1,500 servers
Memory per server100K × ~10 KB per connection~1 GB
Heartbeats150M ÷ one every 30 s~5M/s
TakeawayConnection count, per-socket memory, fanout, TLS, heartbeats, bandwidth, and measured CPU together determine gateway capacity; a socket-only estimate does not prove one server meets the load.shard by conversation
Intuition

A chat server is like a hotel switchboard. It does very little work per call, but it must keep a line open to every guest. That is why we count sockets and memory, not requests per second, to size the fleet.

3. API Design

Stateless calls go over plain HTTPS. Live traffic goes over the WebSocket as small JSON (or protobuf) frames.

# HTTPS (stateless tier)
POST /v1/login                         -> { token, chat_server_url }
GET  /v1/conversations/{cid}/messages?before=<msg_id>&limit=50   # scroll back
POST /v1/groups  { name, member_ids }  -> { group_id }

# WebSocket frames (stateful tier)
client -> server  { type:"send", client_msg_id:"c-9f2e", cid, body }
server -> client  { type:"ack",  client_msg_id:"c-9f2e", msg_id, seq }   # stored: 1 tick
server -> client  { type:"msg",  cid, msg_id, seq, from, body }
client -> server  { type:"delivered" | "read", cid, up_to_seq }          # 2 ticks / blue
client -> server  { type:"ping" }                                        # heartbeat

The client_msg_id is made on the phone before sending. If the network drops and the app resends, the server sees the same id and does not store a second copy.

4. Data Model

Table / storeKeyColumns
messages (Cassandra/HBase-style)partition cid, cluster seq descmsg_id, sender_id, body, created_at, client_msg_id
conversationscidtype (1:1 or group), member_ids, last_seq
user_inbox (per-user sync queue)partition user_id, cluster inbox_seqcid, msg_id
read_state(user_id, cid)delivered_up_to, read_up_to
sessions (Redis, TTL)user_idlist of (device_id, chat_server)
Tip

A 1:1 conversation id can be derived from the two user ids, for example min(a,b) + ":" + max(a,b). Both users then find the same conversation without a lookup.

5. Connection Protocols

The defining challenge of chat is the server-to-client direction. A client can always open a request to send a message, but how does the server deliver a message that arrives for a client at an unpredictable time? Plain request/response does not push. Three techniques bridge that gap, and they sit on a spectrum from wasteful to efficient.

TechniqueHow it worksTradeoff
PollingThe client asks the server "anything new?" on a fixed interval and the server answers immediately, empty or not.Simple, but most polls return nothing — wasted requests. Poll too often and you burn resources; poll too rarely and messages are delayed.
Long pollingThe client asks, and the server holds the request open until it has something to send or a timeout expires, then the client immediately re-asks.Far fewer empty responses, but each message needs a fresh HTTP request, connections are held server-side, and there is still no clean server-initiated path — the server cannot push to a client that is between requests.
WebSocketA single long-lived, full-duplex TCP connection, upgraded from HTTP once at the start. Either side can send a frame at any time.One persistent connection per client to manage, but messages flow in both directions with minimal overhead and true server push.

WebSocket is the preferred choice for the message path. After a one-time HTTP Upgrade handshake, the connection stays open and becomes bidirectional: the client sends outgoing messages over it, and the server pushes incoming messages down the same socket the instant they appear. There is no polling delay and no repeated handshake cost. The price is that the connection is stateful — the server must keep it alive and remember which user is on the other end — which shapes the rest of the architecture.

# client opens one persistent socket and reuses it both ways
ws = websocket_connect("wss://chat.example.com")    # HTTP Upgrade once
ws.on_message(msg -> render(msg))                   # server pushes inbound
ws.send(outbound_message)                           # client sends outbound
# the same socket carries both directions until it closes
Polling and long polling are still fine for low-frequency, mostly-client-initiated features. WebSocket earns its keep specifically because chat needs frequent, low-latency, server-initiated delivery.

6. Service Decomposition

The WebSocket requirement forces a clean split in the backend. Most of the system is ordinary request/response work that any web tier handles well. Only the live connection is special. Separating the two lets each scale on its own terms.

TierExamplesState
Stateless servicesAuthentication, user profile, contacts, group management, the general API.Hold no per-connection state. Any instance can serve any request, so they sit behind a load balancer and scale by adding identical replicas.
Stateful chat serviceThe WebSocket servers that terminate live client connections.Each one holds a set of open sockets and knows which user owns each. A client is bound to one specific server for the life of its connection.

The stateless tier is the easy part: a load balancer spreads requests across interchangeable instances, and login, profile lookups, and group edits all flow through it. The chat service is the hard part precisely because it is sticky. Once a client establishes its WebSocket to a particular chat server, every message for that user must be delivered through that server, because that is where the socket physically lives. You cannot round-robin an inbound message to a random instance and hope it lands on the one holding the connection.

This stickiness is why we need two more pieces that a stateless system would not: a way to find which chat server holds a given user's connection (service discovery), and a way to route a message from the sender's chat server to the recipient's chat server (the message sync queue, the message flow).

7. Storage and Message IDs

Chat history has an unusual access shape, and it points firmly at a key-value / NoSQL store rather than a relational database.

A NoSQL store fits all four: it scales out, handles heavy writes, and serves recency-ordered range reads cheaply. The remaining question is the message id, and it carries two hard requirements at once. The id must be globally unique (no two messages collide) and sortable by time (sorting by id sorts the conversation chronologically), so that ordering a thread is just "read keys in order" with no extra timestamp field to coordinate.

An auto-increment column from a single database would give ordering but does not scale across shards. A random UUID scales but loses time ordering. The standard answer is a Snowflake-style 64-bit id: a single integer packed from a timestamp in the high bits, a machine/shard id in the middle, and a per-millisecond sequence number in the low bits.

# 64-bit id: time-ordered AND unique without coordination
id = (timestamp_ms  << 22)   # high bits: sorts by time
   | (machine_id    << 12)   # which generator node
   |  sequence_number        # disambiguates within the same ms
# sorting ids ascending ~= sorting messages chronologically
# (exact only per generator; clocks on different machines drift)

Because the timestamp occupies the most significant bits, simply sorting ids ascending yields chronological order, while the machine id and sequence number guarantee uniqueness even when many servers generate ids in the same millisecond. That is exactly the property the conversation read path wants.

Watch out: clock skew

Snowflake ids from different machines are only roughly time-ordered, because server clocks drift by milliseconds. Two messages sent a moment apart in the same chat can get ids in the "wrong" order. Within one conversation that matters, so many real systems add a per-conversation sequence number (1, 2, 3, …) assigned by the one server that owns the conversation. It gives a strict order inside the chat, which is all users ever see.

8. Service Discovery

With connections pinned to specific chat servers, the system needs a directory: which chat server currently holds a given user's WebSocket? This is the job of a service discovery / coordination component, commonly ZooKeeper (or an equivalent) for the list of healthy chat servers, plus a fast key-value store such as Redis for the per-user connection map.

Service discovery has two responsibilities. First, when a client connects, it picks the best chat server for that client — typically the geographically closest one with capacity — and records the client → chat-server mapping. Second, it exposes that mapping so any part of the system can look up where a user is connected and route a message to the correct server. When a client disconnects or fails over, the mapping is updated so stale routes do not linger.

Two different directories

The server list is small and changes rarely, which suits ZooKeeper's strongly consistent, low-write design. The user → server map has one entry per online user (about 150M in our estimate) and changes every time a phone reconnects. That churn belongs in a sharded in-memory KV store with a TTL (for example 60 s, refreshed by heartbeats), so a crashed server's entries expire on their own.

# on connect: choose a server and register the mapping
server = discovery.pick_chat_server(user, region)   # closest with capacity
discovery.register(user_id, server)                 # user -> server

# on delivery: find where the recipient is connected
target = discovery.lookup(recipient_id)             # which chat server?
if target is None:
    route_to_offline_path(recipient_id)             # they are not online
Service discovery is what makes the stateful tier usable. Without a reliable user-to-server map, the system has no way to send an inbound message to the one server that can actually deliver it.

9. The 1:1 Message Flow

Now the pieces connect. The diagram below traces a single message from User A to User B, numbered step by step. Each number corresponds to one of the components introduced above.

1:1 chat message flow
A message travels from User A to Chat server 1 (1) and gets a time-sortable id plus a per-conversation sequence number (2). It enters the message sync queue, partitioned by conversation id (3), and is written to the KV store under (conversation id, message id) (4). The Redis connection map, whose entries expire on a TTL, says where User B is connected: their own chat server if online (5.a, 6) or the push notification servers if offline (5.b).

Walking the numbered path:

  1. (1) User A → Chat server 1. User A sends the message over their open WebSocket to the chat server that holds their connection.
  2. (2) Chat server 1 → ID generator. The chat server requests a unique, time-sortable message id from the Snowflake-style ID generator. This stamps the message with its place in the conversation's order (see the clock-skew note in the Storage section).
  3. (3) Chat server 1 → message sync queue. The message, now with an id, is handed to the message sync queue. This queue decouples receiving a message from delivering and persisting it, absorbing bursts and acting as the routing backbone between chat servers.
  4. (4) → KV store. The message is written to the key-value store under (conversation id, message id), so it becomes durable history immediately — independent of whether the recipient is currently reachable.
  5. (5.a) Online path → Chat server 2 → (6) User B. If User B is online, service discovery says which chat server holds their connection (Chat server 2). The message is routed there and pushed down B's WebSocket in real time.
  6. (5.b) Offline path → PN servers. If User B is not connected, there is no socket to push to. The message goes to the push notification (PN) servers, which alert B's device. The message is already safely in the KV store, so B receives it the next time they come online and sync.

The key insight is that persistence (step 4) happens regardless of presence. Delivery is best-effort and immediate when possible, but durability is guaranteed first. Whether the recipient is online only changes how they are reached — a live socket versus a push notification plus a later sync — never whether the message is kept.

10. Delivery Guarantees and Receipts

The sender retains an outbox until server acknowledgement under the chosen durable-replica/failure contract. Namespace client_msg_id by sender/conversation and claim it atomically with persistence and sequence assignment. A lost acknowledgement retries the same identity, not a new message.

Server acceptance, device receipt, and user reading are different events. A cumulative device receipt covers only a contiguous stored prefix; read receipts are separate. Reconnection fetches durable history; gateway push is an optimization. Idempotent storage exposes one logical message within a defined retention horizon, not survival of every conceivable fault.

Persist group membership decisions and pending fanout via transactional work/outbox records. A crash after saving the message but before enqueueing every member must not strand the remaining recipients.

11. Online Presence

Presence — showing whether a contact is online, away, or last seen at a time — rides on the same persistent connection. The chat server uses a heartbeat to know the connection is alive: the client periodically sends a small keep-alive frame, and the server resets a timer each time one arrives.

If heartbeats keep coming, the user is online. If they stop for longer than a threshold (the socket dropped, the app was backgrounded, the network died), the server marks the user offline and records a last seen timestamp. Using a threshold rather than reacting to the first missed beat avoids flapping a user's status on a brief network blip.

# server tracks liveness per connection
on heartbeat(user):
    last_seen[user] = now()
    status[user] = ONLINE

every few seconds:                          # sweep
    for user where now() - last_seen[user] > THRESHOLD:
        status[user] = OFFLINE
        fanout_presence(user, OFFLINE, last_seen[user])

When a status changes, it is fanned out to that user's contacts — but only to contacts who are themselves online and would actually display it, via a presence/fanout path much like the message path. Pushing presence to everyone all the time would be wasteful, so the fanout is scoped to interested, connected viewers. For very large contact lists, presence is often fetched on demand (when you open a conversation) rather than pushed eagerly.

12. Group Chat

Persist one message with a per-conversation committed sequence, then fan out references to member inboxes through durable, retryable work. Message and fanout intent commit together; inbox insertion is idempotent. Define membership at send time and historical access after join/leave.

Devices render canonical sequence order and repair gaps, not just arrival order or timestamp-ID order. Independent fanout can be delayed/duplicated. Bounded groups can use per-member inboxes; large channels may use shared logs and on-read catch-up. A shared ID or a loop over members alone does not guarantee complete, ordered delivery.

13. Multi-Device Sync

Keep a separate durable sync cursor for each device, scoped to a committed inbox or conversation stream. Resume by sequence/offset, not an assumed globally monotonic Snowflake ID. A timestamp-sorted ID can arrive or commit behind an already-seen higher ID and be skipped by a naive id > last_seen query.

Acknowledge the highest contiguous stored sequence. Receiving 12 while 11 is missing must not advance a contiguous cursor from 10 to 12. Fetch the gap, dedup replay, and only then advance. If retention removed the needed prefix, return an explicit resync/snapshot requirement. Membership rules govern history access after join/leave; authorize replay as well as live delivery.

14. Failure Modes

What failsEffectHandling
A chat server crashes~100K sockets drop at once.Clients reconnect with jittered backoff to another server, re-register in the session store, and resume from their last seq. Stale session entries expire by TTL.
Message store shard slow or downSends in those conversations cannot be acked.Replicated store (for example 3 replicas, quorum writes). The client keeps the message in its outbox and retries; no ack means no lie to the user.
Recipient's server lookup is stalePush goes to a server that no longer has the socket.That server drops it; the message is already stored, so the recipient gets it on reconnect sync. Delivery is best effort, storage is not.
Duplicate send after timeoutTwo copies could appear.Dedup on client_msg_id at the server and on msg_id at the client.
Push notification provider downOffline users are not alerted.Queue and retry the push; messages are still in history, so nothing is lost, only delayed.
Reconnect storm after a network blipMillions of clients reconnect in seconds.Random jitter on reconnect, connection rate limits at the load balancer, spare server capacity.

15. Tradeoffs & Alternatives

DecisionOption AOption BUsual choice
Server pushLong polling: works everywhere, more overheadWebSocket: one socket, low latencyWebSocket, long polling as fallback
Message orderSnowflake id: no coordination, approximatePer-conversation sequence: strict, needs one ownerSequence per conversation
Group deliveryFan out a copy per member inboxMembers read one shared group logPer-member for small groups, shared log for huge ones
History storeRelational DB: familiar, harder to scale writesWide-column KV: scales writes, simple queriesWide-column KV (Cassandra, HBase)
EncryptionServer can read messages (search, moderation)End-to-end: server sees only ciphertextProduct choice; E2E makes server-side search impossible

16. Summary

A chat system is a handful of decisions that build on each other, each one made to solve the previous one's consequence:

ConcernMechanism
How does the server push to a client?A persistent, bidirectional WebSocket connection per active client, chosen over polling and long polling.
How do services scale?Stateless services (auth, profile, API) behind a load balancer; a separate stateful chat service holds the live sockets.
Where is history stored?A key-value / NoSQL store: enormous, write-heavy, read by recency, partitioned by conversation id and sorted by message id.
How are messages ordered uniquely?A 64-bit Snowflake-style id — timestamp in the high bits makes ids unique and sortable by time.
How is a user's chat server found?ZooKeeper tracks healthy chat servers; a Redis-style KV store with TTLs holds the user → chat-server map.
How does a 1:1 message travel?Chat server → id generator → message sync queue → KV store → recipient's chat server (online) or push servers (offline).
How is presence tracked?Heartbeats detect online/offline; status changes fan out to connected contacts.
How does group chat differ?Fanout: one copy enqueued into each member's per-user queue, then delivered like 1:1.
How do multiple devices stay in sync?Per-user message sync queue with a per-device last-seen cursor; each device replays what it missed.
The recurring theme: persist first, deliver second. Durability through the message sync queue and KV store never depends on the recipient being reachable; presence and connection state only decide how a message is delivered — a live socket, a push notification, or a later catch-up sync — never whether it survives.

Worked example: message 12 arrives before message 11

The device has stored committed conversation sequences through 10.

Worked example, in orderReceive sequence 12: Buffer it. Do not acknowledge a contiguous cursor of 12 while message 11 is missing.. Reconnect after a gateway crash: Ask the durable stream for entries after contiguous cursor 10, not for IDs greater than the maximum Snowflake seen.. Receive 11 and replay 12: Dedup stored IDs; contiguous state can now advance through 12. Preserve device-specific cursors.. Retry a send with a lost ACK: Reuse (sender,conversation,client_msg_id). A transactional unique constraint returns the original committed message and sequence.1Receive sequence 122Reconnect after a gateway crash3Receive 11 and replay 124Retry a send with a lost ACK
Read top to bottom. The table below explains the state and guarantee at each step.
StepState, calculation, and reasoning
1. Receive sequence 12Buffer it. Do not acknowledge a contiguous cursor of 12 while message 11 is missing.
2. Reconnect after a gateway crashAsk the durable stream for entries after contiguous cursor 10, not for IDs greater than the maximum Snowflake seen.
3. Receive 11 and replay 12Dedup stored IDs; contiguous state can now advance through 12. Preserve device-specific cursors.
4. Retry a send with a lost ACKReuse (sender,conversation,client_msg_id). A transactional unique constraint returns the original committed message and sequence.
Check your understanding: Why is a highest-seen timestamp not a safe resume cursor?

A delayed or concurrently committed message can have an older timestamp/ID. A cursor must represent a complete committed prefix, not approximate time.

Run a small, testable model

Download concepts.py. It uses only the Python 3.10+ standard library. From the directory where you saved it, run:

python3 concepts.py delivery
python3 concepts.py test

The model buffers a gap, deduplicates replay, and verifies that acknowledgement does not skip a missing entry.

The program contains deterministic assertions and failure traces. It is a learning model, not a production service: it does not implement network consensus, distributed durability, authentication, or third-party delivery. Read its contracts and try changing an input before extending it.

Primary references for these concepts

17. 45-Minute Interview Walkthrough

TimePhaseWhat to cover
0–5 minRequirements1:1 and group size, history, receipts, DAU. State "at least once, dedup to exactly once on screen".
5–10 minEstimationMessages/s, storage/year, concurrent sockets → server count.
10–15 minAPI + data modelWebSocket frames with client_msg_id; messages keyed by (cid, seq).
15–25 minHigh-level designStateless vs stateful tiers, discovery, the 1:1 flow with online and offline paths.
25–40 minDeep divesAcks and retries, ordering, group fanout, presence cost, multi-device cursors.
40–45 minWrap-upFailure handling, E2E encryption as an extension, metrics (send-to-deliver latency, reconnect rate).

18. Common Mistakes

19. Check Yourself

  1. Why is the chat service stateful while the profile service is not?
    Answer

    A chat server holds open WebSockets. A user's socket lives on exactly one server, so messages for that user must go to that server. Profile requests can go to any instance.

  2. With 150M concurrent users and 100K sockets per server, how many chat servers do you need?
    Answer

    150,000,000 ÷ 100,000 = 1,500 servers, plus spare capacity for failures and reconnect storms.

  3. The sender's phone times out and resends a message. How does the system avoid storing it twice?
    Answer

    The phone reuses the same client_msg_id. The server sees it has already stored that id for this sender and returns the original ack instead of writing again.

  4. Why might two Snowflake ids from different servers be out of order in real time?
    Answer

    The timestamp bits come from each server's own clock, and clocks drift by milliseconds. A per-conversation sequence number from one owner fixes the order inside a chat.

  5. User B is offline when A sends. Walk through what happens.
    Answer

    The message is stored and acked to A. Discovery finds no socket for B, so a push notification is sent. When B opens the app, the device sends its last seq and syncs everything newer.

  6. Why does a device ack "up to seq 812" instead of acking each message?
    Answer

    It is cheaper (one small frame for many messages), and it doubles as the device's cursor: on reconnect the server resends everything after 812.

20. Key Terms & Further Reading

WebSocket
A long-lived, two-way connection upgraded from HTTP (RFC 6455).
Stateful service
A server that holds per-client data, here open sockets, so requests must reach a specific instance.
Service discovery
A directory that says which server holds which user's connection.
At-least-once delivery
Resend until acknowledged; duplicates are possible and removed by id.
Idempotency key
A client-generated identity for one logical operation. The server binds it to caller scope, endpoint, request fingerprint, state, and result. A retry uses the same key and reuses the original outcome rather than repeating the effect. Example and limits above.
Heartbeat
A small periodic ping that proves a connection is alive.
Snowflake id
A 64-bit id built from time, machine id and a counter.
Sync cursor
The last sequence number a device has, used to resume after reconnect.

⌂