WhatsApp is a widely used instant messaging application that enables real-time communication between users through instant message delivery. Users can send text messages, media files, and other content to individuals or groups, with messages delivered within milliseconds.
Loading simulation...
The core idea is simple: User A sends a message, and User B receives it instantly. However, achieving this at scale with billions of users, while ensuring message delivery guarantees, handling offline users, and supporting group conversations, introduces significant distributed systems challenges.
Other Popular Examples: Facebook Messenger, Telegram, Signal, WeChat
In this chapter, we will work through the high-level design of a messaging system like WhatsApp.
This problem is a favorite in system design interviews because it touches on so many fundamental concepts: real-time communication, persistent connections, message ordering, delivery guarantees, and the challenges of building a truly global-scale system.
Let's start by understanding what exactly we are building.
Before diving into the design, it's important to ask thoughtful questions to uncover hidden assumptions, clarify ambiguities, and define the system's scope more precisely.
Here is an example of how a discussion between the candidate and the interviewer might unfold:
Candidate: "What is the expected scale? How many users and messages per day should the system support?"
Interviewer: "Let's design for 500 million daily active users (DAU) sending an average of 40 messages per day."
Candidate: "Should we support only one-on-one messaging, or also group chats?"
Interviewer: "Both. Group chats should support up to 500 members."
Candidate: "What types of content should messages support? Text only, or also media like images and videos?"
Interviewer: "Focus on text messages for the core design. You can mention media handling at a high level, but detailed media processing is out of scope."
Candidate: "Do we need to show online/offline status and typing indicators?"
Interviewer: "Yes, presence indicators (online/offline/last seen) are important. Typing indicators are nice-to-have."
Candidate: "What about message delivery guarantees? Should users see read receipts?"
Interviewer: "Yes. Users should see when their message is delivered and when it's read. Messages should never be lost."
Candidate: "Should messages be stored permanently, or can they expire?"
Interviewer: "Messages should be stored until explicitly deleted by the user. We need to support message history sync across devices."
Candidate: "What about end-to-end encryption?"
Interviewer: "You can mention it conceptually, but detailed cryptographic implementation is out of scope."
After gathering the details, we can summarize the key system requirements.
To keep our discussion focused, we will set aside a few features that, while important, would expand the scope beyond a single interview:
With our requirements clear, lets understand the scale we are dealing with. In most interviews, you are not required to do a detailed estimation.
We will use these baseline numbers throughout our calculations:
Let's start with the fundamental question: how many messages flow through this system?
Twenty billion. That is 20,000,000,000 messages every single day. Let's convert that to something more tangible:
The 3x multiplier accounts for peak hours when everyone is awake and chatting. Traffic is never uniform throughout the day.
These numbers tell us something important: we are looking at hundreds of thousands of concurrent operations per second. This rules out naive approaches like having clients poll the database for new messages. We need persistent connections, efficient routing, and aggressive caching.
Messaging systems differ from typical web applications here. Unlike a website where users make requests and disconnect, a messaging app needs to push messages to users the instant they arrive.
That means maintaining persistent connections with every online user.
Each of these 50 million connections requires maintaining a persistent WebSocket. This is a fundamentally different challenge from handling 50 million HTTP requests per day. These connections stay open, consuming memory and file descriptors on our servers.
If a single well-tuned server can handle 50,000 concurrent WebSocket connections (a reasonable estimate for modern hardware with proper kernel tuning), we need:
That covers the average load. To absorb the ~100 million peak, we provision roughly double, around 2,000 servers. For connection handling alone, we are looking at a fleet of a couple thousand servers.
Storage requirements for a text-only system are more modest than you might expect:
730 TB/year is well within reach of distributed databases like Cassandra or ScyllaDB (with replication, plan for roughly 3x). The real challenge is not capacity but access patterns: we write 230,000 messages per second while simultaneously reading history and syncing devices, so latency matters more than raw throughput.
Inbound is modest: 230K msg/sec x 100 bytes is about 23 MB/sec. Outbound is higher because group messages fan out to multiple devices, but even so we stay in the range of hundreds of megabytes per second, which modern network infrastructure handles easily. Bandwidth is not the bottleneck for text messaging.
Defining the APIs early forces us to think concretely about what users can do and what data flows through the system. A messaging system's API is unusual: most real-time communication happens over persistent WebSocket connections rather than HTTP request-response, but we still need REST endpoints for operations that do not require instant delivery, like fetching message history.
Let's walk through the essential APIs.
WebSocket message or POST /messagesThis is the most frequent operation in the system. When a user taps send, this API handles getting the message from their device to ours.
In practice, this almost always goes over the WebSocket connection for lowest latency, but having a REST fallback is useful when WebSocket connections fail.
The client_message_id matters for reliability. If the network drops and the app retries a send, the server uses this ID to recognize the duplicate and store the message only once, which gives effectively-once delivery.
GET /conversations/{conversation_id}/messagesWhen a user opens an old conversation or logs in on a new device, they need to see their message history. This endpoint retrieves messages for a conversation, typically the most recent ones first.
We use cursor-based pagination rather than offset-based. With billions of messages, a query like OFFSET 1000000 would be slow, requiring the database to skip over a million rows. Cursor-based pagination uses an indexed value (like a message ID or timestamp) to efficiently jump to the right position.
POST /messages/{message_id}/statusThis is what powers those checkmarks. When a message is delivered to the recipient's device or opened by the user, we need to update its status and notify the sender.
Status updates flow in one direction: sent → delivered → read. We never go backwards. The timestamp helps with edge cases where status updates arrive out of order due to network delays.
GET /users/{user_id}/presenceReturns whether a user is currently online and, if offline, when they were last active. This powers the "online" indicator and "last seen" text in the UI.
Presence is intentionally kept simple. We do not need to know exactly what a user is doing, only whether they are actively connected. Privacy controls allow users to hide their last seen time, in which case we omit that field.
With our API contract defined, we have a clear picture of what the system needs to do. Now let's design the architecture that makes these APIs work at scale.
We will build this system incrementally rather than presenting a full architecture diagram up front and explaining each box afterward.
We will start with the simplest possible design that solves our first requirement, then add components only as we encounter new challenges. This mirrors how you should think through the problem in an interview.
Our system must ultimately satisfy three core requirements:
Before designing the architecture, one property shapes the rest: messaging is fundamentally a push-based system.
Think about how a typical web application works. Your browser requests a page, the server responds, and the connection closes. If you want new data, you request again. This request-response pattern works great for most applications, but it falls apart for messaging.
You cannot expect users to constantly refresh to check for new messages. The moment a message arrives at our servers, we need to push it to the recipient's device immediately.
This push-based nature is why we need persistent WebSocket connections rather than traditional HTTP. And maintaining millions of persistent connections creates a whole set of challenges that we need to address.
Let's start building, one requirement at a time.
Let's start with the simplest possible scenario: User A sends a message to User B, and User B is currently online with the app open.
What do we need to make this work?
With both users online, we push the message to User B the instant it arrives over a persistent connection. The question is which components make that happen.
Let's introduce the components one by one, understanding why each exists.
Chat servers handle most of the system's work. Each one maintains persistent WebSocket connections with thousands of clients simultaneously.
When User A opens the messaging app, their phone establishes a WebSocket connection to one of the chat servers. This connection stays open for as long as the app is in use. When User A sends a message, it travels over this existing connection, no need to establish a new one.
Beyond holding connections, a chat server routes messages to recipients that may live on other chat servers, sends heartbeats to detect dead connections, and handles reconnection when users switch networks.
Chat servers are stateful. Unlike typical web servers where any server can handle any request, User B's messages must go to the specific chat server where User B's connection lives. If User B is connected to Chat Server 2, sending their message to Chat Server 1 will not help.
This statefulness creates a routing challenge. When User A sends a message to User B, how does Chat Server 1 know that User B is on Chat Server 2?
This is where the Session Service comes in. It maintains a simple but critical mapping: which user is connected to which chat server.
When User B connects to Chat Server 2, that server registers the connection: "User B is on Chat Server 2." When User A wants to send a message to User B, we query the Session Service: "Where is User B?" It responds: "Chat Server 2."
We typically implement this using Redis because it offers exactly what we need: fast key-value lookups with built-in expiration for handling disconnections. The data structure is simple:
While routing messages in real-time is essential, we also need to persist them. Users expect to see their message history. If User B's phone dies right as a message arrives, we do not want to lose it.
The Message Service persists every message to the database before attempting delivery, generates server-side message IDs and timestamps for ordering, tracks message status (sent, delivered, read), and serves message history queries for the Fetch Messages API.
Now let's trace what happens when User A sends "Hey, how's it going?" to User B. Both users are online, connected to different chat servers.
Let's walk through the flow:
When User A taps send, the message travels over the existing WebSocket to Chat Server 1, which first asks the Message Service to persist it. Persisting before anything else means the message survives even if a later step fails.
The Message Service writes it to the database and returns a server-generated message ID and timestamp; the server's clock, not the client's, is the source of truth for ordering. With the message stored, Chat Server 1 queries the Session Service ("Where is User B?") and gets back "Chat Server 2" in under a millisecond.
Chat Server 1 then forwards the message to Chat Server 2, typically over gRPC, and Chat Server 2 pushes it to User B. Direct server-to-server forwarding is simple, but maintaining a full mesh across thousands of chat servers is hard to manage.
A common alternative is a publish/subscribe backplane (Redis Pub/Sub or Kafka) where each server subscribes to a channel for the users it holds, so the sender publishes to the recipient's channel instead of addressing a specific server.
Finally, User B's client acknowledges receipt, the status updates to "delivered" along the way, and User A's UI shows the double checkmark. When both users are online, this round trip completes in under 100 milliseconds.
This raises the next question: what happens when User B is not online?
The flow we designed works when both users are online. Real-world messaging is messier than that. What happens when User B's phone is in airplane mode? What if they have not opened the app in hours? What if they are in a subway tunnel with no signal?
We cannot drop the message; that would violate our reliability requirement. Users expect that once they tap send, the message eventually arrives, even if the recipient is unreachable for hours or days. So instead of pushing a message and forgetting about it, we need to track pending deliveries and retry when users come back online.
Let's introduce two new pieces to our architecture.
The message is already persisted in the message database, so durability is covered. What we still need is a way to know which messages a user has not yet received, plus a pipeline to deliver them once the user returns.
We track delivery progress with a per-user marker: the ID of the last message the user's device has acknowledged. Any message newer than that marker is pending. When the user reconnects, we read everything after their marker straight from the message store, in order.
This is the same catch-up mechanism we use for multi-device sync later in the design, so offline delivery and device sync share one path instead of two.
For the active delivery path (attempting delivery, retrying, and waking devices through push), we use a queue like Kafka. One detail matters here: we do not create a topic per user, which at our scale would mean hundreds of millions of topics, something Kafka does not handle well.
Instead, a small number of partitioned topics carry delivery tasks, partitioned by recipient ID so each user's tasks stay ordered. The queue handles retries and backpressure; the database remains the durable source of truth for the messages themselves.
Even though we cannot deliver the message content directly to an offline user, we can still tell them something is waiting. This is where push notifications come in.
The Push Notification Service integrates with Apple Push Notification Service (APNs) for iOS and Firebase Cloud Messaging (FCM) for Android. It sends a notification like "New message from User A" to wake up the device, while respecting user preferences such as muted conversations and quiet hours.
Let's trace what happens when User A sends a message but User B is offline.
The diagram traces the two paths Chat Server 1 can take after persisting the message: forward it directly if User B is online, or route it through the message queue and push notification pipeline if not.
Once User B reconnects, the chat server reads their last-delivered marker and fetches every pending message from the store in one pass, ensuring nothing sent during the offline period is skipped.
With this design, the message is never lost. Whether User B comes online in 10 seconds or 10 days, the message will be waiting, because it is persisted to the database before anything else and the queue is only an optimization for fast delivery.
So far we have handled one-on-one messaging. But groups introduce a new challenge that fundamentally changes our design: fanout.
Consider this scenario: User A sends "Happy New Year!" to a group with 500 members, our maximum size. That single message needs to reach 500 different devices, potentially scattered across dozens of different chat servers, some members online and some offline, some on fast WiFi and some on spotty mobile networks.
With one-on-one messaging, one input means one output. With groups, one input means many outputs. This multiplier effect is called fanout, and it can easily overwhelm a naive implementation.
Let's visualize what happens when a message goes to a group:
If User A sends a message to a group with 500 members (our maximum), and the sender's chat server has to individually deliver to all 500, we have a problem. That server becomes a bottleneck, and if it crashes mid-delivery, some members get the message and some do not. Large groups would also create noticeable delays.
There are several ways to handle fanout. Let's examine each and understand their trade-offs.
The simplest approach is to have the sender's chat server do all the work. When User A sends a group message, Chat Server 1 looks up all group members, finds their chat servers, and delivers to each one.
This approach works fine for small groups (under 50-100 members), which are the majority of groups in typical usage patterns.
For larger groups, we can use a message queue to distribute the work across multiple workers.
We can combine both approaches, choosing based on group size:
The threshold of 100 is not fixed; it is a tunable parameter based on your server capacity. Different group sizes warrant different delivery strategies: low latency for the common case of small groups, and scalable distributed fanout for large ones.
Let's put it all together and trace a group message from send to delivery:
group_id instead of a single recipient. The message is now durable.This flow handles groups of any size efficiently. For small groups, it completes in tens of milliseconds. For larger groups using the queue-based approach, delivery might take a bit longer but remains reliable.
We have now addressed each requirement incrementally. Let's step back and see the complete picture. This is the architecture you would draw on the whiteboard after explaining each component:
The system breaks into clear layers. Clients (mobile and web) are WebSocket clients. The edge layer load-balances incoming connections, using sticky sessions (or consistent hashing by user ID) so a connection stays on the same chat server.
The chat layer is stateful, since each server holds specific users' connections, while the service layer (messages, groups, users, presence) is stateless and scales horizontally. The data layer combines Redis (sessions and presence), Kafka (delivery pipeline), Cassandra (message history), and PostgreSQL (user and group data).
With the high-level architecture clear, let's look at how we store all this data efficiently.
With 20 billion messages per day and 500 million users, database choices matter. The wrong one becomes a bottleneck that is hard to fix later.
A messaging system has two very different kinds of data, and each deserves its own storage strategy.
Messages have a clear access pattern:
A wide-column NoSQL database like Apache Cassandra or ScyllaDB fits these patterns: log-structured storage absorbs high write throughput, adding nodes scales capacity linearly, clustering keys keep each conversation sorted by time on disk, and consistency is tunable per query.
User and group data is different:
A relational database like PostgreSQL fits these needs: joins and complex filters for the relationships, ACID transactions for atomic multi-table updates, and strong consistency so reads always see the latest writes.
Now the schemas. Each table is shaped around its access pattern and stored in the technology best suited to it:
This is the central table in our storage layer. The schema design is driven by a single question: "What is the most common query we need to answer?"
For a messaging app, that query is: "Get the last 50 messages for this conversation, ordered by time."
We design the entire table around this access pattern:
| Field | Type | Description |
|---|---|---|
conversation_id | UUID (Partition Key) | Unique identifier for the conversation |
message_id | TimeUUID (Clustering Key) | Time-based UUID for ordering |
sender_id | UUID | ID of the message sender |
content | Text | Message content |
content_type | Text | Content kind: text, image, video |
status | Text | Delivery status: sent, delivered, read |
created_at | Timestamp | Server timestamp |
Partition Key (conversation_id): This determines which nodes store the data. All messages in a single conversation live together on the same nodes. When we query "last 50 messages for conversation X", Cassandra knows exactly which nodes to ask. This is what makes reads fast.
A natural question is where conversation_id comes from, since the Send Message API only carries sender_id and recipient_id. We derive it deterministically so both participants resolve to the same partition. For a one-on-one chat, we sort the two user IDs and hash the pair, for example conversation_id = hash(min(A, B) + max(A, B)).
Sorting first guarantees that A messaging B and B messaging A land in the same conversation. For a group chat, the conversation_id is the group_id, which already uniquely identifies the conversation.
This is why the Send API takes a recipient_type: it tells the server whether to derive the ID from a user pair or use the group ID directly. The client never has to know the conversation_id; the server computes it on write, and the Fetch Messages API uses the same derivation to read.
Clustering Key (message_id as TimeUUID): Within a partition (a conversation), messages are physically sorted on disk by the clustering key. A TimeUUID is a special UUID that encodes the timestamp, so messages are automatically ordered by time. Fetching "the last 50 messages" becomes a simple range scan, not a full table scan.
Together, these keys let the common query hit a single partition and read data that is already sorted.
One caveat: partitioning purely by conversation_id lets a busy, long-lived conversation grow without bound, and very large partitions hurt Cassandra's read performance. In practice we add a time bucket to the partition key, for example (conversation_id, year_month), so each partition covers a bounded window. Reads for recent messages hit the current bucket, and older history pages into earlier buckets.
When a user opens the app, the first thing they see is their conversation list. We need to answer: "What are this user's recent conversations, and what was the last message in each?"
| Field | Type | Description |
|---|---|---|
user_id | UUID (Partition Key) | User ID |
last_message_at | Timestamp (Clustering Key, DESC) | Time of last message |
conversation_id | UUID (Clustering Key) | Conversation ID |
unread_count | Integer | Number of unread messages |
last_message_preview | Text | Preview of last message |
We denormalize last_message_preview and unread_count directly into this table so rendering the conversation list takes one read instead of a join or a second query against the messages table. Storing data in the shape you read it, even at the cost of duplication, is a common Cassandra pattern.
The clustering key is last_message_at in descending order, not conversation_id. This matters because the conversation list is shown most-recent-first, and clustering by last_message_at means Cassandra returns the rows already in that order. The trade-off is that last_message_at changes every time a new message arrives, and a clustering key cannot be updated in place.
So on each new message we delete the old row and insert a new one at the updated position, which moves the conversation to the top of the list. Because we are rewriting the row anyway, the new row carries the recomputed unread_count and last_message_preview, so a regular column write is enough; we do not need a separate Cassandra counter.
Updating it is a per-recipient operation, not a per-message one. A one-on-one message touches two rows (one per participant). A 500-member group message touches up to 500 rows, one for each member's conversation list.
So a single group send becomes a fanout of writes here, on top of the one durable write to the messages table.
This is an acceptable trade because the conversation list is read far more often than it is written, and these writes are small and can be applied asynchronously after the message is durably stored.
For very large or very active groups, this fanout is also a reason to cap group size and to update each member's list lazily (for example, only refreshing unread_count when that member is online or opens the app) rather than writing to all 500 rows on every message.
Group metadata lives in PostgreSQL where we can use proper relational modeling:
| Field | Type | Description |
|---|---|---|
group_id | UUID (PK) | Unique group identifier |
name | VARCHAR(100) | Group name |
creator_id | UUID (FK) | User who created the group |
created_at | Timestamp | Creation time |
member_count | Integer | Number of members |
The member_count is denormalized here even though we could compute it from the members table. This avoids a COUNT query every time we need to display group info.
This is the join table that maps users to groups:
| Field | Type | Description |
|---|---|---|
group_id | UUID (PK, FK) | Group ID |
user_id | UUID (PK, FK) | User ID |
role | VARCHAR(20) | Role: admin, member |
joined_at | Timestamp | When user joined |
The composite primary key (group_id, user_id) serves two purposes:
With these tables, we can handle all group operations with standard SQL queries and proper transaction support. When a user joins a group, we update both the membership table and the group's member_count in a single transaction.
The single status field on the messages table is enough for a one-on-one chat, where a message has exactly one recipient. Group chats break this assumption. A message sent to 50 people is delivered and read by each member at a different time, and we cannot represent "delivered to 30 of 50, read by 12" with one column.
We track per-recipient status in a separate table:
| Field | Type | Description |
|---|---|---|
message_id | TimeUUID (Partition Key) | The message being tracked |
user_id | UUID (Clustering Key) | A recipient of the message |
status | Text | Per-recipient status: delivered, read |
updated_at | Timestamp | When this recipient's status last changed |
Partitioning by message_id keeps all receipts for a single message together, so answering "who has read this?" is a single-partition read. For a one-on-one chat this table holds one row per message; for a group, one row per member.
The sender's UI shows the double checkmark once every recipient has a delivered row, and blue checkmarks once everyone has a read row. The messages table itself stays small, storing each message only once.
Now let's move on to the deep dive into specific challenges.
The high-level architecture gives us the skeleton. The deep dive is where you show that you understand how the components work and why one approach beats another. Let's explore the more challenging parts of building a messaging system.
We have used WebSocket connections throughout this design, but why WebSocket specifically? There are several ways to achieve real-time communication between client and server, each with different trade-offs in latency, resource usage, and complexity.
Long polling is the oldest technique for achieving real-time-like behavior with plain HTTP. It predates WebSockets and was the backbone of early real-time web apps like Gmail's chat.
The idea is simple: the client makes an HTTP request asking "any new messages for me?" Instead of responding immediately with "no," the server holds the connection open. If a new message arrives while the connection is open, the server responds with it immediately.
If nothing happens for 30-60 seconds, the server responds with an empty result, and the client immediately makes another request.
The client creates a continuous loop of requests, effectively creating a "persistent" connection using standard HTTP semantics.
Long polling got us through the early web era, but it is not ideal for a modern messaging system with millions of concurrent users.
SSE improves on long polling by establishing a true persistent connection, but only in one direction. The server can push events to the client continuously, but the client still needs to use regular HTTP requests to send data back.
Think of SSE as a one-way pipe from server to client. The server can push events whenever it wants, but sending a message back requires a separate HTTP POST.
SSE is a good fit for notification streams, live feeds, or stock tickers where the server broadcasts and the client mostly listens. For chat, where both sides constantly send data, we need something better.
WebSocket is the modern solution. It provides a true bidirectional channel where both client and server can send messages at any time, over a single persistent TCP connection.
The connection starts with a standard HTTP request that includes an "Upgrade" header. If the server supports WebSocket, it responds with 101 Switching Protocols, and from that point on, the connection is a full WebSocket. Both sides can send frames whenever they want, there is no request/response dance.
| Approach | Latency | Overhead | Bidirectional | Best For |
|---|---|---|---|---|
| Long Polling | High | High | No | Legacy systems, fallback |
| SSE | Medium | Low | No | Notifications, live feeds |
| WebSocket | Lowest | Lowest | Yes | Chat, gaming, collaboration |
For a messaging system, WebSocket is the best fit. The bidirectional nature matches how chat works: both users send and receive constantly, the low latency keeps conversations feeling instant, and a single connection per user lets each server hold more of them. Its one real drawback, the stateful nature, we have already handled with the Session Service.
Always implement long polling as a fallback. Some corporate networks and older proxies still block WebSocket connections. Your client should detect this and gracefully fall back to long polling
Everyone who has used WhatsApp knows the checkmarks: one gray for sent, two gray for delivered, two blue for read. These simple icons hide a lot of complexity.
How do we track message state reliably across unreliable networks, flaky mobile connections, and devices that go offline unpredictably?
Let's break down what each state means and how we guarantee correct transitions:
Networks fail, devices go offline mid-delivery, and the same message can be sent twice due to retries, so getting these transitions right takes care.
The sequence below maps each checkmark transition to the exact server event that triggers it, showing how a single "Hello!" moves from the sender's tap through persistence, delivery, and the final read acknowledgment:
The cardinal rule of messaging: messages must never be lost. A user who sees the "sent" checkmark should be confident that their message will eventually reach its destination, even if networks fail, servers crash, or the recipient's phone runs out of battery.
Achieving this requires a combination of two techniques: the client retries aggressively, and the server deduplicates.
Reliable messaging relies on one idea: if the server can detect duplicate messages, the client can retry as many times as needed without the message appearing twice.
Here is how this works in practice:
client_message_id (typically a UUID). This ID is the message's fingerprint.client_message_id.client_message_id before?"This is idempotent delivery: the same send can be retried any number of times, and the server still stores and delivers it once.
There is one more critical rule for reliable messaging: never acknowledge a message until it is persisted to durable storage.
If the server crashes between receiving a message and persisting it, the message is lost. By only sending ACK after persistence, we guarantee that any acknowledged message is safely stored.
A single connection preserves order, since TCP delivers bytes in sequence. Messages can still arrive out of order for other reasons: a message that timed out and was retried, messages sent from a user's different devices, or messages that travel through different servers and queues on the way to the recipient.
The order is established on the server, not the client. The client's clock cannot be trusted (it may be wrong or deliberately set), so we never order by the client timestamp.
message_id we use as the clustering key in the messages table, so the database stores messages in this exact order. This is the authoritative order for a conversation.The green "online" dot and "last seen at 3:45 PM" text seem like simple features. But think about what they require at scale: tracking 50 million concurrent users, notifying their contacts when status changes, and doing it all without overwhelming the system.
Presence is a trade-off between accuracy and efficiency, and the hard part is doing it at scale.
The core challenges with presence are:
The practical solution is a combination of heartbeats for tracking and lazy queries for display.
The mechanism is simple:
presence:user_123 = online with a TTL of 30 seconds.The 30-second TTL is a deliberate choice. It means users appear offline within 30 seconds of going offline, which is acceptable for casual chat. If you needed faster detection (for a stock trading app, say), you could reduce the TTL and heartbeat interval, at the cost of more traffic.
Broadcasting every status change to all contacts does not scale, so instead we query presence only when it is needed:
When User A opens a chat with User B:
This drastically reduces presence traffic. We only track presence for users the client is actively viewing.
Instead of binary online/offline, many apps show "last seen at [time]":
last_seen timestamp on every meaningful user actionThis provides useful information without the complexity of real-time presence. A common variation is to compute live "online" status only for a conversation the user currently has open, and fall back to "last seen" everywhere else.
Chat servers are stateful: each one holds specific users' WebSocket connections, so a user's messages must reach the exact server they are connected to. That makes them harder to scale than stateless API servers. Let's walk through how to handle it.
A well-tuned server with proper kernel configuration can handle 50,000 to 100,000 concurrent WebSocket connections, bounded by file descriptors, memory, and CPU.
As estimated earlier, covering 50 million average (and ~100 million peak) connections at 50,000 each puts the fleet on the order of 1,000 to 2,000 servers, with headroom so a spike or the loss of a few servers does not exhaust capacity.
Because a user's connection is pinned to one server until they disconnect, the load balancer must keep routing that user to the same place. Common options:
What happens when a chat server crashes? With 50,000 users per server, a crash is a significant event.
The recovery flow:
Message persistence (in the database and queue) is separate from connection state. Even if a server crashes mid-delivery, the message is safe and will be delivered on reconnection.
Production systems need regular maintenance: OS patches, code deployments, hardware replacements. Graceful shutdown minimizes user impact:
Most clients will reconnect to other servers during the drain period, making the maintenance nearly invisible to users.
With the core design in place, a few extensions commonly come up as follow-up questions. Two worth being ready for are syncing messages across a user's devices and end-to-end encryption.
Modern users expect their messages on every device: phone, tablet, laptop, web browser. When they read a message on their phone, it should show as read on their laptop too. This is multi-device sync.
A single incoming message must reach all of a user's connected devices immediately, and queue for the ones that are offline, so the state stays consistent regardless of which device the user picks up next.
When a message arrives for User A, we need to:
The best approach combines real-time push with catch-up pull:
When User A has multiple devices connected, the Session Service tracks all of them:
When a message arrives:
When a device comes online after being offline:
The device sends its last sync timestamp when connecting. The server fetches all messages since then and delivers them in bulk. This ensures no messages are ever missed, regardless of how long the device was offline.
When User A reads a message on their phone:
All of User A's devices see the same read status. The sender (User B) also gets notified that the message was read.
End-to-end encryption (E2EE) ensures that only the sender and recipient can read messages. Even the service provider (WhatsApp, Signal, etc.) cannot decrypt message content.
Most modern messaging apps use the Signal Protocol or something similar:
The basic flow:
With E2EE in place, the server's role is limited to routing and storage. The diagram separates what the server retains the ability to do from what it permanently loses access to once encryption is in force:
Benefits: E2EE provides strong privacy protection, makes users trust the system more, and helps with regulatory compliance in some regions.
For an interview, it's sufficient to mention that E2EE is important for privacy and explain the high-level concept. The cryptographic details (perfect forward secrecy, double ratchet algorithm, etc.) are typically out of scope unless the interviewer specifically asks.
20 quizzes