Skip to content

    System Design

    System Design Case Study: Chat System

    Design a chat system: WebSocket gateways, presence, per-conversation storage, delivery and acknowledgements, ordering, offline push, group fan-out and encryption.

    By Published 18 min read

    Share

    This lesson is part of Advanced System Design and Case Studies. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to (1) design a gateway tier that holds millions of long-lived connections, (2) store and order messages per conversation, (3) explain delivery acknowledgements, offline delivery and group fan-out, and (4) describe the main encryption options and their consequences.

    Think of a post office with a very particular rule: letters must reach the recipient within a second if they are standing at their front door, and must be held safely if they are not home. The post office needs to know who is home (presence), keep a line open to every house that is (connections), store letters for the absent (offline storage), and tap on the window of those who stepped out (push notifications).

    A chat system is that post office. The hard parts are not the messages themselves, which are tiny. The hard parts are keeping millions of connections open, knowing which server each user is attached to, keeping messages in order, and never losing one that was acknowledged.

    We assume the Foundations and Building Blocks courses, especially message queues and sharding.

    The core idea

    Separate three jobs that are easy to mix up:

    1. Connection handling: a fleet of stateful gateway servers holding long-lived WebSocket connections.
    2. Message routing: deciding which gateways should receive a message right now.
    3. Message storage: a durable, per-conversation log that is the source of truth.

    The durable log makes delivery reliable: a message is stored first, then delivered, and the client confirms. The gateways are just a fast lane on top of storage.

    Requirements and scope

    Functional: one-to-one chat, group chat (up to a few hundred members in this design), message history, delivery and read receipts, online presence, push notifications for offline users, multi-device sync.

    Out of scope: voice and video calls, very large broadcast channels (they behave more like a news feed, see the news feed case study), media storage internals (see the photo sharing case study; attachments are uploaded to object storage and referenced by URL).

    Non-functional (assumptions):

    • Message delivery to an online recipient: p99 well under a second.
    • No acknowledged message is lost (durability).
    • Messages within one conversation appear in the same order for all participants.
    • Availability over perfect consistency for presence. Slightly stale "last seen" is fine.

    Back-of-envelope estimate

    All numbers are illustrative assumptions.

    Quantity Assumption Result
    Daily active users 100 million
    Concurrent connections at peak 20% online at once 20 million connections
    Messages sent per user per day 40 4 billion messages per day, about 46,000 per second average
    Peak factor 3x about 140,000 messages per second
    Average message size 200 bytes of content plus metadata, say 300 bytes stored 1.2 TB per day, about 440 TB per year before replication
    Connections per gateway 100,000 (assumption, depends on memory and tuning) 200 gateways at peak, more for headroom
    Group fan-out Average group of 5, a few big ones of 200 Deliveries are a few times the number of messages

    Connection count, not message rate, sizes the gateway tier. Memory per connection (buffers, TLS state, session data) usually bounds how many one machine holds. Message rate and storage size size the storage tier.

    API design

    Clients speak a framed protocol over a WebSocket (a long-lived bidirectional connection started from an HTTP upgrade). The HTTP API handles everything that is not real-time.

    WS   /v1/connect                             authenticated upgrade (credential in a header, cookie or first frame, not in a logged URL)
    
    client -> server: { "type": "send", "convId": "c_9", "clientMsgId": "uuid", "body": "hi" }
    server -> client: { "type": "ack", "clientMsgId": "uuid", "msgId": "m_123", "seq": 481 }
    server -> client: { "type": "message", "convId": "c_9", "msgId": "m_123", "seq": 481, "from": "u_1", "body": "hi" }
    client -> server: { "type": "delivered", "convId": "c_9", "upToSeq": 481 }
    
    GET  /v1/conversations/c_9/messages?beforeSeq=481&limit=50
    POST /v1/conversations                       create group

    Two fields matter. clientMsgId is generated by the client so a retry after a dropped connection does not create a duplicate (idempotency). seq is a per-conversation sequence number assigned by the server, which defines order.

    Data model

    Messages are append-only and read by conversation, newest first. That access pattern suits a partitioned wide-column store (Cassandra-style) or a sharded key-value or relational store.

    • Messages: partition key conv_id, clustered by seq descending. One conversation lives on one partition, so history reads are sequential and fast. Row: (conv_id, seq, msg_id, sender_id, body, created_at). De-duplication by clientMsgId needs its own lookup, for example a small table or cache keyed by (conv_id, client_msg_id) with a TTL, since the main table is ordered by seq.
    • Conversations and membership: conversation(conv_id, type, ...) and members(conv_id, user_id) with a reverse index user_conversations(user_id, conv_id, last_seq, last_read_seq) to render the inbox.
    • Sessions: which gateway holds which device connection: session(user_id -> [device_id, gateway_id]). This is small, hot and ephemeral, so it fits an in-memory store with TTLs. It is refreshed by heartbeats.
    • Offline queue / sync cursor: each device remembers the last seq it has per conversation. Sync is "give me everything after seq N", so no separate per-user queue is strictly required.
    NOTE

    Very large conversations make a single partition grow without bound. Add a time bucket to the partition key (for example conv_id plus month) so partitions stay a manageable size.

    erDiagram USERS ||--o{ MEMBERS : joins CONVERSATIONS ||--o{ MEMBERS : has CONVERSATIONS ||--o{ MESSAGES : contains USERS ||--o{ SESSIONS : connects CONVERSATIONS { string conv_id PK string type long last_seq } MEMBERS { string conv_id FK string user_id FK long last_read_seq } MESSAGES { string conv_id PK long seq PK string msg_id string sender_id string body } SESSIONS { string user_id FK string device_id string gateway_id }

    How to read it: MESSAGES is keyed by conv_id and seq, which is exactly the history read path. SESSIONS is the only ephemeral table and can be rebuilt from heartbeats.

    High-level design

    Chat high-level designDevice A to Gateway 1 (WebSocket); Device B to Gateway 2 (WebSocket); Gateway 1 to Message service; Message service to Sequencer per conversation; Sequencer per conversation to Message store; Message service to Router; Router to Session store; Router to Gateway 2; Router to Push service; Push service to Offline device; Gateway 1 to Presence storeDevice ADevice ADevice BDevice BOffline deviceOffline deviceGateway 1Gateway 1Presence storePresence storeGateway 2Gateway 2Push servicePush serviceMessage serviceMessageserviceRouterRouterSession storeSession storeSequencer per conversationSequencer perconversationMessage storeMessage storeWebSocketWebSocket

    Gateways are mostly dumb: terminate TLS, authenticate, keep the socket, and forward frames to the message service. The message service validates, assigns the sequence number, stores the message, then asks the router to deliver. The router looks up recipients' sessions and hands the message to the right gateways. If a recipient has no live session, it goes to the push service.

    sequenceDiagram participant A as Sender participant G1 as Gateway 1 participant M as Message service participant S as Message store participant R as Router participant G2 as Gateway 2 participant B as Recipient A->>G1: send with clientMsgId G1->>M: forward M->>S: assign seq and persist M-->>G1: ack with seq G1-->>A: ack M->>R: route to recipients R->>G2: deliver G2->>B: message B-->>G2: delivered up to seq G2->>M: record delivery

    The sender's ack means "stored", not "delivered". Delivery and read are separate, later events. That distinction is what turns "sent, delivered, read" ticks into honest status.

    stateDiagram-v2 [*] --> Sending Sending --> Sent : server stored and acked Sending --> Sending : retry same clientMsgId Sent --> Delivered : recipient cursor passes seq Delivered --> Read : read cursor passes seq Sending --> Failed : retries exhausted Failed --> Sending : user taps retry Read --> [*]

    How to read it: only the server ack moves a message out of Sending, and later states are driven by cursors, not by individual receipts.

    Reference deployment topology

    The deployed system has a stateful gateway tier in front of the services (the message service is partitioned by conversation, the router and push service are stateless), with the log and the session store in the data tier.

    Chat reference deployment topologyL4 load balancer to Gateway pods; Gateway pods to Message service; Message service to Message store; Message service to Router; Router to Redis; Router to Gateway pods; Router to Push service; Gateway pods to RedisEDGEGATEWAY TIER, STATEFULSERVICESDATA TIERL4 load balancerL4 loadbalancerGateway pods: several zonesGateway podsseveral zonesMessage service: by conv partitionMessageserviceby conv partitionRouterRouterPush servicePush serviceMessage store: Cassandra-styleMessage storeCassandra-styleRedis: session storeRedissession storeRedis: presence storeRedispresence store

    How to read it: gateways are the only tier that holds connections, so they are the only tier whose deploys need draining. The message service is partitioned by conversation so one owner assigns each seq.

    Deep dive 1: connection gateways and presence

    Why a stateful tier. WebSockets are long-lived, so the server holds per-connection state. That breaks the usual "any server can handle any request" rule for this tier. Behind the gateways, durable state lives in the stores, and the only per-process state is partition ownership in the message service.

    Load balancing connections. An L4 or L7 load balancer spreads new connections, and because a connection lasts hours, balancing happens at connect time. Deploys and scale-down must drain gracefully: tell clients to reconnect elsewhere, with random jitter and a capped reconnect rate. A deploy that drops 100,000 connections per node, all reconnecting at once, creates a reconnect storm that can take down the next tier.

    Session lookup. On connect, the gateway writes (user, device) -> gateway into the session store with a TTL, renewed by heartbeat. The router queries it to find where to send. If the entry is stale (the gateway died), delivery fails fast and the message falls back to storage and push. Clients reconnect and sync by sequence number, so nothing is lost.

    Presence. The client sends a heartbeat every N seconds, the gateway refreshes a key with a TTL slightly above N, and expiry means offline. Do not broadcast every change to every contact: let clients subscribe only to conversations on screen, and debounce flapping with a short delay before announcing "offline". Presence is the classic place to accept eventual consistency and approximate values.

    Approach to presence Use when Cost Alternative
    Heartbeat with TTL key Default, simple Heartbeat traffic, up to one TTL of staleness Connection-state events only
    Fan-out to all contacts Tiny friend lists Explodes with large contact lists Subscribe only for visible chats
    Pull on demand Rarely shown presence Higher latency on open Cache with short TTL

    Deep dive 2: ordering, acknowledgements and delivery guarantees

    Ordering. Clocks across devices disagree, so timestamps cannot define order (see IDs, clocks and ordering). Instead, route all writes for a conversation through a single logical owner that assigns an increasing seq. In practice, partition the message service by conv_id (hash the conversation ID to pick a partition) and process each partition's messages serially. Clients then sort by seq. Ordering is guaranteed per conversation, not globally, and that is all users need.

    If the owner fails, another node takes over the partition. Its leadership must be established safely (see consensus and leader election) so two nodes never assign the same seq. A message log such as Kafka gives per-partition ordering for free if you key by conv_id (see event streaming), though you still have to design the consumer side.

    Delivery semantics. The honest guarantee is at-least-once delivery with idempotent receipt. The server retries until the client acknowledges, and the client discards duplicates using msgId or seq. The effect of "exactly once" comes from that combination, not from the network itself. Clients also detect gaps: if they have seq 480 and receive 482, they request 481 from history.

    The reliable-send loop from the sender:

    1. Generate clientMsgId, show the message locally as "sending".
    2. Send over the socket. If no ack within a timeout, retry with the same clientMsgId.
    3. The server de-duplicates by (conv_id, clientMsgId), returns the original seq.
    4. Show "sent" on ack.

    Delivered and read receipts. Treat them as cumulative cursors: "delivered up to seq 481" and "read up to seq 479" per user per conversation. A cursor is smaller and cheaper than one receipt per message and survives lost packets, because a later cursor covers earlier ones.

    Multi-device. Each device holds its own socket and its own sync cursor. Delivery fans out to all of a user's devices. Read cursors are synchronised so reading on the phone clears the badge on the laptop.

    Deep dive 3: offline delivery and group chat

    Offline users. The message is already in the store, so nothing needs a special queue. The router sees no live session and calls the push service, which sends a notification through the platform push providers (APNs for Apple devices, FCM for Android). Push is best-effort: it can be delayed, dropped or throttled, and it should carry minimal content (or just a "new message" hint) because it passes through third parties. When the app opens, it connects and syncs from its last seq. Push is a nudge, the log is the truth.

    Group chat fan-out. A group message is stored once (one row in the conversation's partition) and delivered to each member. For small groups, the router fans out to each member's sessions. The scale lever is the same one as in feeds: push to online members now, let offline members catch up by sync. For larger groups, route per gateway instead of per member: send one copy to each gateway that has any member, and let the gateway fan out locally. That cuts internal traffic from "members" to "gateways involved".

    Group delivery approach Use when Cost Alternative
    Per-member delivery via router Small groups Many internal messages for big groups Per-gateway batching
    Per-gateway batching Groups with hundreds of members Gateways need a local member index Pub/sub channel per group
    Pub/sub topic per group Very large rooms Subscription management at scale Treat as a broadcast feed

    The choice of group delivery path follows from room size and how clustered the members are.

    flowchart TD M["Group message stored"] --> A{"Members under about 20?"} A -->|yes| B["Router delivers per member session"] A -->|no| C{"Members under a few hundred?"} C -->|yes| D["One copy per gateway, local fan-out"] C -->|no| E["Pub sub topic or treat as broadcast channel"] B --> F["Offline members get push and sync"] D --> F E --> F

    How to read it: the thresholds are tuning knobs, not rules. The last step is the same for every branch because the stored log is the guarantee.

    Membership changes are events in the same log, so every device agrees on who was in the group at which seq. That matters for what a new member can read (history policy) and for encryption keys.

    Encryption options (high level)

    • In transit: TLS on every connection. This is the baseline.
    • At rest: encrypt storage volumes or fields with keys held in a key management service.
    • End-to-end encryption: messages are encrypted on the sender's device and only decrypted on recipients' devices, so the server stores ciphertext it cannot read. Well-known designs use per-device keys and a ratcheting protocol to limit how much a stolen key exposes. The consequences are real: the server cannot search or moderate message content, server-side history backfill to a new device needs a client-assisted key transfer, groups need key distribution on every membership change, and multi-device multiplies keys. Do not design your own cryptography: use an established, audited protocol and library.
    IMPORTANT

    Choose encryption by threat model and product needs. End-to-end encryption protects content from the operator and from a server breach, but removes server-side features that rely on reading content. State this trade-off explicitly rather than treating it as a free upgrade.

    Bottlenecks and scaling

    • Gateway memory and file descriptors. Tune per-connection buffers, use event-driven networking, and scale out horizontally. Keep gateway logic thin so a deploy is cheap.
    • Session store. About 20 million entries are small but hot. Shard by user_id and replicate.
    • Message store write rate. About 140,000 writes per second at peak is comfortable for a sharded, write-optimised store with append-heavy access. Partition by conversation, and watch for hot conversations (a huge, busy group).
    • Router fan-out. Batch per gateway. Keep the router stateless.
    • Reconnect storms. Jittered exponential backoff on the client, connection rate limits at the edge, and gradual drains.

    Failure modes

    Failure Effect Mitigation
    Gateway crash Its users disconnect Clients reconnect with jitter and sync by seq. Session entries expire by TTL
    Duplicate send after retry Double message De-duplicate by clientMsgId
    Message store partition unavailable Cannot persist Reject with a retryable error, never ack unstored messages
    Router sends to stale session Message not delivered live Fall back to push and sync, since the log has it
    Sequencer owner fails over Possible duplicate seq if unsafe Safe leader election and fencing, or store-enforced uniqueness on (conv_id, seq)
    Push provider outage No offline notifications Retry with backoff, rely on sync at next app open
    Reconnect storm Cascading overload Jitter, rate limits, gradual drain
    Presence flapping Noisy UI Debounce offline announcements

    Trade-offs worth stating

    Decision Option A Option B How to choose
    Transport WebSocket Long polling or server-sent events plus HTTP WebSocket for bidirectional low latency. Fallbacks for restrictive networks
    Ordering Per-conversation sequencer Client timestamps Sequencer. Timestamps cannot be trusted
    Delivery At-least-once with dedup At-most-once At-least-once. Losing messages is worse than duplicates
    Storage layout Partition by conversation Partition by user inbox Conversation for history reads. Inbox view via a small index
    Encryption Server-readable End-to-end Threat model versus search, moderation and recovery features

    Interview cheat sheet

    Step What to say Key numbers (illustrative)
    Requirements 1:1 and groups, history, receipts, presence, push 100M DAU
    Estimate Connections size gateways, messages size storage 20M connections, 140k messages/s peak, about 440 TB per year
    API WebSocket frames with clientMsgId and seq, HTTP for history Idempotent sends
    Data Messages partitioned by conversation, session store, inbox index Time-bucketed partitions
    Design Gateways, message service with sequencer, router, push Store first, then deliver
    Deep dives Gateway drain and presence, ordering and at-least-once, offline and group fan-out Cumulative receipts
    Failures Gateway loss, reconnect storm, stale sessions, push outage Sync by seq
    Trade-offs Encryption versus features, ordering strategy, fan-out per member versus per gateway Threat model

    In practice: operating the reference design

    NOTE

    A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.

    Assume a team of twelve runs this on Kubernetes across three zones. The gateways use a plain WebSocket server behind an L4 load balancer, the message store is a Cassandra-style wide-column database, sessions and presence live in Redis, and Prometheus and Grafana provide monitoring.

    SLOs and dashboards

    SLI SLO (assumed) Dashboard panels
    Send acknowledged 99.95% of sends acked within 500 ms Ack latency, store write latency, retry rate
    Live delivery p99 under 1 s to online recipients Router to gateway latency, stale-session fallback rate
    Durability Zero acked messages lost Seq gap detector per conversation, store error rate
    Connection health 99.9% of connects succeed Connects per second, connections per gateway, reconnect rate
    Push dispatch 99% handed to provider within 5 s Push queue age, provider error codes

    Capacity-review cadence

    • Weekly: connections per gateway against the 100,000 assumption, memory per connection, partition size outliers, hot conversations.
    • Monthly: storage growth against about 440 TB per year before replication, tombstone and compaction health.
    • Quarterly: a reconnect-storm drill (drain one zone and measure recovery time), and a load test at twice the assumed peak of 140,000 messages per second.
    • Before major events: holidays and large broadcasts shift message rates, so re-run the estimate.

    Runbook

    Symptom Likely cause First check Mitigation
    Messages delayed for online users Router or session store slow Session lookup latency, stale session rate Scale router, bypass stale entries, fall back to sync
    Connect failures spike Reconnect storm after a deploy or incident Connects per second by gateway, client backoff logs Rate-limit at the edge, drain gradually, widen jitter
    Gateway memory climbing Buffer leak or slow consumers Memory per connection, send queue depth Disconnect slow clients, drain and restart pods in small waves
    Duplicate seq error in the store Two owners after a failover Partition ownership changes, fencing token logs Reject the stale owner, repair the conversation
    No offline notifications Push provider outage or credential expiry Provider error rates and auth errors Retry with backoff, rely on sync at open
    One conversation slow Hot partition Partition size and write rate Time-bucket the key, throttle the sender

    Decision record: per-conversation sequencer

    Field Entry
    Context Timestamps cannot order messages and a global sequence is a bottleneck. Users need a single order per conversation.
    Decision Partition the message service by conv_id. One owner per partition assigns seq, guarded by a fencing token. As a backstop the store rejects a second write to the same (conv_id, seq), which in a Cassandra-style store needs a conditional insert (slower), so it is used selectively rather than as the only protection.
    Consequences (good) Strict per-conversation order, simple clients, cheap gap detection.
    Consequences (bad) A hot conversation limits one owner, failover needs safe leadership, and cross-conversation order is not defined.
    Revisit when One conversation sustains write rates that saturate its owner, or failovers produce more than a handful of repairs per month.

    Cost view

    Gateways (memory per connection), storage and cross-zone traffic dominate. Message payloads are small, so per-message compute is rarely the issue.

    Cost driver Lever
    Gateway fleet Lean per-connection buffers, tune connections per node, scale with headroom for drains
    Message storage Retention policy, compression, time-bucketed partitions, tiering old history
    Cross-zone traffic Prefer same-zone calls between gateway, message service and router where possible, batch per gateway
    Push Collapse notifications, send minimal payloads

    Architect's notes

    • Measure memory per connection first. The gateway count follows from it, and an unmeasured estimate can be off by a wide margin.
    • Make clients part of the design review. Backoff, jitter and sync logic live in the apps, and a bad release can cause the outage.
    • Pin down the history policy early. Who can read history, for how long, and what a new group member sees affects storage, encryption and legal obligations.
    • Do not let the gateway become a service. Every feature added to the stateful tier makes deploys scarier.
    • Defer end-to-end encryption debates with facts. List which features (search, moderation, backfill) depend on server-readable content before deciding.
    • Page the right team. Gateways, the store and the push integration usually have different owners, so write the runbook entry points per owner.

    Common mistakes

    • Ordering by client timestamps. Clock skew reorders messages. Assign a server-side sequence per conversation.
    • Acking before persisting. A crash then loses an acknowledged message. Ack only after the durable write.
    • Treating push notifications as the delivery channel. Push is best-effort. The stored log and sync are the guarantee.
    • Broadcasting presence to every contact. Traffic explodes. Subscribe only to what is visible and debounce.
    • Putting business logic and state in the gateways. Deploys then drop connections and lose data. Keep gateways thin and state external.
    • No client backoff or jitter. After an outage every client reconnects at once. Use exponential backoff with jitter.
    • Claiming end-to-end encryption while the server reads content. Users are misled. Be precise about which parties can read messages.

    Key takeaways

    • Store, then deliver. The durable per-conversation log is the truth, and connections are a fast lane.
    • Connections size the gateway tier. Memory per connection, not message rate, is the limit.
    • Order with a sequence number. One owner per conversation assigns seq. Clocks cannot be trusted.
    • At-least-once plus idempotent receipt. Retries and de-duplication give the effect users expect.
    • Cumulative cursors beat per-message receipts. They are cheaper and survive loss.
    • Push is a nudge. The app syncs from its last sequence number when it opens.
    • Presence is approximate by design. Use heartbeats, TTLs, subscriptions and debouncing.
    • End-to-end encryption has costs. Search, moderation and recovery change when the server cannot read content.

    Practice exercises

    Exercise 1 — Starter (30 min): For the illustrative numbers in this lesson, recompute the gateway count if each node holds 50,000 connections instead of 100,000 and you want 30% headroom. Then compute messages per second and yearly storage if users send 80 messages per day instead of 40.

    Exercise 2 — Intermediate (2 hours): Write the sync protocol precisely: what a device sends when it reconnects after two hours offline, how the server responds for a one-to-one chat and for a group with 5,000 unread messages across 12 conversations, how gaps are detected, and how you bound the response size. Draw the sequence diagram.

    Exercise 3 — Advanced (half day): Design the failover of the sequencer for a conversation partition. Describe how a new owner is chosen, how you prevent two owners from assigning the same seq (consider a fencing token checked by the store), what a client sees during the switchover, and how you test it with fault injection.

    Enroll to keep reading

    Lesson 12 of 14 · Advanced System Design and Case Studies

    Design a chat system: WebSocket gateways, presence, per-conversation storage, delivery and acknowledgements, ordering, offline push, group fan-out and encryption.

    In the rest of this lesson

    • High-level design
    • Reference deployment topology
    • Deep dive 1: connection gateways and presence
    • Deep dive 2: ordering, acknowledgements and delivery guarantees
    • Deep dive 3: offline delivery and group chat
    • Encryption options (high level)

    Enrolling is free. It unlocks every lesson in Advanced System Design and Case Studies, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments