Design Chat System
Design a Slack/Discord-style real-time chat system at 10M concurrent users. Covers stateful chat servers with sticky WebSocket routing, the subs:{channel_id} Redis set for channel subscriber tracking, throttled typing broadcasts (1Hz, capped to first 50 viewers), batched presence updates (5s), multi-device fan-out via conn:{user_id} SET, Cassandra for messages sharded by (workspace_id, channel_id), and Elasticsearch for search fed async by Kafka.
Foundational.
How it works
What are we designing? A general-purpose real-time chat system like Slack or Discord. Users join workspaces (servers), belong to channels (rooms), and exchange messages in real time. The system supports typing indicators, presence ('online now'), message edits/deletes, threaded replies, file attachments, and search. Unlike WhatsApp (mobile-first, push-driven), chat systems are often desktop-first with always-open apps that hold the connection for hours.
The defining challenge is presence and typing at scale: a single Slack workspace can have 100K users across 10K channels, and every keystroke in a channel must be broadcast to every other viewer of that channel within 200 ms. Multiply by every active channel, every active user, and you have a brutal fan-out problem on a tiny, latency-sensitive payload.
Functional requirements.
- Send a message to a channel (text + optional files + optional thread).
- Receive messages in real time when the channel is open.
- Typing indicators ('Alice is typing...').
- Presence: see who's online in the workspace.
- Edit / delete messages.
- Threaded replies.
- Search across message history.
- Push notifications when offline or mentioned.
Non-functional requirements.
- Message delivery latency: p99 < 200 ms for online channel members.
- Typing broadcast latency: < 200 ms.
- Presence update latency: < 5 s.
- Availability: 99.95% per workspace (a workspace outage is a business stopper).
- Durability: no message loss; history preserved for years.
- Scale: 10M+ concurrent users across 1M+ workspaces; some workspaces have 100K+ members.
Non-goals. No voice/video calls (separate system), no workflow automation (Slack Workflows, etc.).
Capacity estimation.
Messages. Assume 50M messages/day globally. Each message ~1KB (text + metadata + file refs). 50M x 1KB = 50 GB/day = ~18 TB/year. Tiny by modern standards.
Concurrent connections. 10M concurrent users, each holding 1 WebSocket for their session. A chat server holds ~100K connections (Slack uses ~50K/host). Need ~100-200 chat servers globally.
Typing indicators. Each user in an active channel types ~10 chars/sec while composing, generating ~1 typing event/sec. With 1M active channels averaging 5 viewers each = 5M typing broadcasts/sec. This dwarfs message traffic — typing is the real load.
Presence. 10M users, presence updates every 30 s = 333K presence events/sec. Each event broadcasts to every workspace member viewing the member list — say 100 viewers per workspace = 33M presence broadcasts/sec at the extreme. We need to throttle / batch presence updates.
Search. 50M messages/day indexed. Search index ~5x message size = 250 GB/day. Elasticsearch cluster sharded by workspace.
Storage. Messages stored in sharded SQL (per-workspace Postgres) or Cassandra. 5-year retention: 50M x 365 x 5 = 91B messages x 1KB = ~91 TB. Fits in Cassandra.
APIs. The chat system uses WebSocket for real-time frames and REST for non-real-time operations.
WS /v1/connect (auth token, workspace_id in handshake)
-> frame: MSG_SEND { channel_id, text, thread_id?, client_msg_id }
<- frame: MSG_ACK { server_msg_id, ts }
<- frame: MSG_RECV { channel_id, sender_id, text, server_msg_id, ts }
-> frame: TYPING { channel_id, is_typing }
<- frame: PRESENCE { user_id, status }
-> frame: MSG_EDIT { server_msg_id, new_text }
-> frame: MSG_DELETE { server_msg_id }
POST /v1/messages (REST fallback for offline send)
GET /v1/channels/:id/messages?before=...
GET /v1/search?q=...&channel=...
POST /v1/files (presigned S3 URL for upload)The WebSocket is the primary transport; REST is used when no connection is open (mobile notifications reply via REST) and for paginated history fetches.
Data model.
Messages (Cassandra, partitioned by (workspace_id, channel_id), clustered by server_msg_id):
messages (
workspace_id BIGINT,
channel_id BIGINT,
server_msg_id TIMEUUID, -- Snowflake, sortable
sender_id BIGINT,
text TEXT,
thread_id BIGINT NULL,
edited_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
file_urls LIST<TEXT>,
ts TIMESTAMP,
PRIMARY KEY ((workspace_id, channel_id), server_msg_id)
)Channels (per-workspace SQL):
channels (id, workspace_id, name, type ENUM('public','private','dm'), created_at)
channel_members (channel_id, user_id, joined_at)Connection registry (Redis, mapping user_id -> set of chat_server_ids):
key: conn:{user_id}
value: SET of chat_server_ids (a user may have multiple devices)
TTL: 60sChannel subscribers (Redis, mapping channel_id -> set of online user_ids):
key: subs:{channel_id}
value: SET of user_ids currently viewing the channel
TTL: 300s -- refreshed by client heartbeatsWhen a message arrives for channel C, we lookup subs:C and broadcast to each subscriber's chat server.
Presence (Redis):
key: presence:{workspace_id}
value: HASH { user_id: status (active|away|offline) }Updates throttled to every 30s per user.
Deep dive: presence, typing, and the broadcast fan-out.
The hardest part of a chat system is not storing messages — that's a solved problem (Cassandra, partitioned by channel). The hard part is broadcasting small, high-frequency events (typing, presence, edits) to every viewer of every channel, within 200 ms, without melting the system.
The subs:C set. When a user opens channel C, their client subscribes via the chat server, which does SADD subs:{C} user_id. When they switch channels, SREM from the old, SADD to the new. The chat server reads subs:C to know who to broadcast to. TTL 300s means a closed laptop stops receiving broadcasts within 5 minutes.
Typing throttling. A naive implementation sends a typing event on every keystroke. At 10 chars/sec per user, this is 10 broadcasts/sec per typing user per channel. We throttle to 1 event/sec per (user, channel) — clients render an 'Alice is typing...' indicator that fades after 3 seconds, so 1Hz is enough to keep it alive.
Presence batching. Presence updates for 10M users at 30s interval = 333K events/sec. Each event broadcasts to every workspace member viewing the member list — potentially 100K viewers for a large workspace. Naive: 33M broadcasts/sec. We batch: presence updates are aggregated per workspace and pushed every 5s; clients render a slightly-stale member list.
Multi-device fan-out. A user with 3 devices (desktop, phone, tablet) has 3 WebSocket connections, possibly to 3 different chat servers. The conn:{user_id} key holds a SET of chat_server_ids. Every broadcast to user X must reach all 3 servers. We do this by sending to each chat_server_id in the set.
Message edits and deletes. Edits broadcast an MSG_EDIT frame to subs:C with the new text and server_msg_id; clients replace the message locally. Deletes broadcast MSG_DELETE; clients replace the message with '(deleted)'. The Cassandra row is updated with edited_at / deleted_at; reads filter deleted messages.
Search indexing. Every message publish also goes to a Kafka topic per workspace; an indexer writes to Elasticsearch. Search returns message_ids; the client fetches full messages from Cassandra. Search is decoupled from the chat path — a search outage doesn't break chatting.
Idempotency and ordering. Same as WhatsApp: client_msg_id for idempotency; Snowflake TIMEUUID for ordering within a channel. Channel messages are clustered by server_msg_id, so reads are in send order.
Bottlenecks and failure modes.
-
Typing broadcast storm. A 1000-person channel where everyone is typing generates 1000 broadcasts/sec to 1000 viewers = 1M fan-out writes/sec for one channel. Mitigation: cap typing broadcasts to the first 50 viewers (you can't render 1000 typing indicators anyway); sample typing events at 1Hz per user.
-
Chat server failure. All connections on a dead chat server must reconnect. Mitigation: sticky-session rebalancing; clients reconnect with exponential backoff; new chat server re-subscribes to all the user's open channels.
-
Hot channel. A 100K-person company-wide channel where every message broadcasts to 100K subscribers. Mitigation: broadcast via a tree (the sender's chat server forwards to N 'aggregator' chat servers, each of which fans out to their local subscribers).
-
Redis failure. Without Redis, we can't look up subscribers; broadcasts fail. Mitigation: Redis cluster with replicas; fall back to broadcasting only to users on the sender's chat server (degraded mode).
-
Search index lag. Recently posted messages aren't searchable for 5-30s. Mitigation: accept it; show 'this message is being indexed' for the sender.
-
Push notification spam. A user mentioned in 5 channels while offline generates 5 separate push notifications. Mitigation: coalesce notifications into one 'you have 5 new mentions' push, with deep links.
-
Workspace-level isolation failure. A bug in one workspace affecting another. Mitigation: shard by workspace_id; per-workspace rate limits; per-workspace keyspaces in Cassandra.
-
Message edit race. Two devices edit the same message simultaneously. Mitigation: last-write-wins by
edited_attimestamp; clients fetch the final version on reconnect.
Scaling strategy and trade-offs.
Chat server scaling. Add chat servers horizontally. Use sticky routing (hash of user_id -> chat server) so reconnects are cheap. A dead chat server's users reconnect with backoff.
Redis cluster. Shard by user_id for conn: keys, by channel_id for subs: keys, by workspace_id for presence. Cross-shard operations (e.g. broadcasting to a channel whose subscribers span many shards) require fan-out at the application layer.
Cassandra scaling. Shard by (workspace_id, channel_id). Each channel's messages live on one partition (cheap reads). For very active channels (company-wide), sub-partition by day.
Multi-region. Run chat servers in 5+ regions. Each user connects to nearest. Cross-region broadcasts go through a backbone RPC. Messages are written to a single-region Cassandra primary and asynchronously replicated.
Search scaling. Elasticsearch cluster per workspace (or per-shard for large workspaces). Use rollover indices (one per month) so old indices can be searched lazily.
Trade-offs made explicit.
- We chose per-connection stateful chat servers — gained low-latency broadcasts, lost rebalancing ease (mitigated by sticky routing + client backoff).
- We chose Cassandra for messages — gained write throughput and time-series friendliness, lost JOINs (hydrating a thread requires a separate query).
- We chose 1Hz typing throttle — gained bounded broadcast load, lost UI responsiveness (acceptable; clients render the indicator for 3s).
- We chose 5s presence batch — gained presence traffic manageable, lost real-time presence accuracy (acceptable; users won't notice 5s staleness).
- We chose async search indexing — gained no impact on chat latency, lost instant search for very recent messages.
In a 1000-person channel, every user is typing simultaneously. What's the broadcast load, and how do you mitigate it?
Pick one answer.
A user has 3 devices (desktop, phone, tablet) connected to 3 different chat servers. A message arrives for them. How is it delivered to all 3?
Pick one answer.
Engineering mental model
Mental model. Think of Design Chat System as a deliberate boundary in a system. The boundary exists because something becomes harder to manage when everything is done in one place: latency, scale, failure isolation, consistency, cost, or team ownership. The useful question is not “what does Design Chat System mean?” but “what pressure makes this boundary worth introducing, and what new failure mode does it create?”
Before choosing Design Chat System, name the workload, the critical user path, the dominant bottleneck, the failure you are trying to absorb, and the trade-off you are willing to accept. If you cannot name those five things, the design is probably premature.
// Pseudocode
request = receive()
result = design_chat_system(request)
return result
// Production questions:
// 1. What happens on timeout?
// 2. Can this operation be retried safely?
// 3. What is the bottleneck?Back-of-the-envelope reasoning
Numerical lens: write down traffic, payload size, read/write ratio, peak multiplier and durability target before choosing a component. The numbers should justify the architecture.
Interactive thought experiment: Design Chat System
Change the variables below and predict what breaks first in Design Chat System. The production lab can later reuse these same inputs.
Change one variable at a time. Predict the failure mode first, then move the slider and see whether your mental model matches the simplified system response.
If you are stuck on Design Chat System, start by drawing the request path and marking every network hop, stateful component, queue, cache and failure boundary. Then estimate where the system will saturate.
You increase traffic by 10× in a system using Design Chat System. What should you inspect first?
Pick one answer.
Which statement is the safest engineering habit when using Design Chat System?
Pick one answer.
You have dashboards for traffic, latency, errors and saturation. You can change the architecture, but every change has operational cost.
Production scenario: your system uses Design Chat System, traffic suddenly spikes, and p99 latency doubles. What is your first move?
Interview drill
Answer this without notes: When would you choose Design Chat System, and when would you intentionally avoid it? Mention at least one bottleneck it addresses, one failure mode it introduces, and one alternative. Then quantify the workload you are designing for.
A useful engineering lens for Design Chat System: define the problem it solves, the simpler design that fails first, the constraint that forces you to introduce this concept, and the new failure modes the concept creates.
Numerical sanity check
Back-of-the-envelope reasoning beats fake precision. State your traffic, payload, concurrency and growth assumptions explicitly, then calculate enough to know whether the current architecture is orders of magnitude away from the target.
Do not optimize for a memorized definition. Reason from the workload and failure mode.
Imagine the simplest version of a system using Design Chat System. What breaks first as traffic grows by 10×, and what would you change before reaching 100×?
Pick one answer.
What you gain, what you pay
- +Sticky WebSocket routing keeps reconnects cheap and broadcasts local.
- +Throttled typing + capped viewer list bounds the worst-case broadcast load.
- +Multi-device fan-out via conn:{user_id} SET delivers to every device.
- +Async Kafka -> Elasticsearch decouples search from the chat path.
- −Typing broadcasts can melt a Redis shard without throttling + viewer capping.
- −Per-connection stateful servers complicate rebalancing on failure.
- −Presence staleness (5s) is acceptable but limits real-time apps.
- −Search index lag (5-30s) makes very recent messages unsearchable briefly.
How this breaks in production
- Typing broadcast storm in large channels — needs 1Hz throttle + 50-viewer cap.
- Chat server death causes reconnection thundering herd — needs backoff.
- Hot channel for company-wide broadcasts — needs tree-based fan-out.
- Redis failure kills broadcasts — needs cluster + degraded-mode fallback.
- Multi-device edit race — needs last-write-wins on edited_at.
Don't fall into these traps
- •Broadcasting typing to every viewer without throttling — melts Redis.
- •Single WebSocket per user (no multi-device) — breaks mobile + desktop together.
- •Storing presence in Cassandra instead of Redis — too slow for 5s updates.
- •Sync search indexing on the chat path — adds latency to message sends.
- •Per-user Redis keys instead of per-channel subs:C — 1000x more lookups.
Real systems using this
How real systems implement this
- Slack — Stateful chat servers with sticky WebSocket routing, Redis for presence and channel subscriptions, Cassandra-style store for messages. Documented in their engineering blog.
- Discord — Originally used Elixir + Phoenix for chat servers; migrated to Rust + ScyllaDB (C++ Cassandra) for higher throughput. Handles 10M+ concurrent voice users.
- Microsoft Teams — Built on top of Skype's infrastructure with Azure Service Bus for fan-out. Uses Exchange Online for message storage.
Practice saying it out loud
- Q1Design a chat system like Slack. How do you handle typing indicators?
- Q2A user has 3 devices open. How do you deliver a message to all 3?
- Q3A 1000-person channel has everyone typing. What breaks, and how do you fix it?
- Q4How do you make search not block the chat path?
Further reading & references
Core explanations are original NO CAP material. External references are provided for deeper study and standards.
What next?
Mark as understood once the mental model clicks.
Next recommended
Design WhatsApp