Design WhatsApp: the presence and receipt problems most candidates ignore
Quick Overview
This resource explains how to design a scalable WhatsApp-style chat system for system design interviews and real-world messaging infrastructure. It covers the core delivery loop, WebSocket connections, offline message inboxes, Redis connection registries, Kafka partitioning, per-user ordering, ack-then-delete reliability, client-side deduplication, presence heartbeats, TTL-based online status, delivery/read receipts, and fan-out strategies for small and large group chats. It is designed for software engineers, backend engineers, site reliability engineers, and interview candidates who want to reason clearly about messaging systems instead of memorizing diagrams. The guide is valuable because it separates delivery, presence, and receipts into distinct traffic problems, explains why one Kafka topic per user fails at scale, and shows the tradeoffs senior interviewers expect candidates to understand.
Here's how to handle all three without burying the message pipe, plus the Kafka mistake that quietly costs you fifty terabytes.

A single blue paper plane frozen mid-flight against flat deep green, one soft shadow. Clean and high-contrast, standing in for one message getting across.
The fastest way to fail this design is one Kafka topic per user
Look, everyone reaches for the same clever idea in this interview. Give every user their own Kafka topic, route messages into it, done. It feels tidy. It is a trap.
Kafka was not built for billions of topics. Each one carries real overhead, on the order of fifty kilobytes of metadata and file handles. Do the multiplication for a billion users and you have signed up for fifty terabytes of storage before a single message is sent. The broker falls over long before that.
A topic per user is the design smell that tells a senior interviewer you have never run Kafka in production.
So park the clever idea. The real problem is smaller and harder: three different traffic problems disguised as one.
What the thing actually has to do
Before you draw a single box, pin the loop. Someone sends a message. That's the whole product right there, everything else is scaffolding bolted around that one act. If the recipient is online, it lands in well under a second. If they're offline? It waits. Then it turns up the instant they reconnect, in the right order, exactly once, no gaps and no dupes. And then, because one message is never enough, you pile presence on top (online, last seen) and receipts on top of that (sent, delivered, read, the blue ticks that have started more fights than any product feature has a right to).
Four requirements, then. And no two of them want the same thing from your design. The trap is drawing WebSockets before you have said any of this out loud. Requirements first, sockets second.
Honestly, most of the difficulty is not delivery. It is that presence and receipts generate their own traffic, on their own curves, and if you shove them down the message pipe they bury it.
Why WebSocket, with long-poll as the one fallback
You need the server to push. Plain request-response cannot, so polling means either stale chats or hammering the server with empty checks. You want one long-lived, bidirectional connection per device, held open both ways. WebSocket is the answer. The one fallback worth naming is long-polling, for clients stuck behind proxies that break the upgrade. You mention it and move on.
Here's what most people skip: once the socket is open, where is it, actually? A's phone holds a connection to one specific chat server. B's phone holds a connection to a different one. Nothing in the world knows which is which yet.
So you keep a registry. A simple Redis map, user_id -> server_id.
on connect
SET conn:{user_id} {server_id} EX 60 # who holds this socket, expire if we die
on disconnect
DEL conn:{user_id} # free it cleanly
When a server needs to reach a user, it asks Redis which server holds that socket, then routes there. This registry is the spine of the whole system. Get it right and everything else is plumbing.
The delivery path, and where the message waits
Here is the send path, end to end.
A sends over its socket to its chat server, call it CG1. First thing CG1 does is acknowledge A. Not "delivered", just "I have it". That ack is what stops A's client retrying, and it powers the single grey tick.
CG1 writes the message to durable storage keyed by recipient, an inbox for B. This is the bit that makes offline delivery work: the message lives somewhere real before B has seen it.
Now CG1 asks the registry where B is. Two cases.
If B is offline, you stop. The message sits in B's inbox until B reconnects, at which point B's server drains the inbox in order and pushes it down the socket.
If B is online on CG2, CG1 hands the message to CG2 so CG2 can push it. This cross-server hop is where Kafka belongs, and it is where the topic-per-user idea dies. Use a small, fixed number of shared topics, and partition by recipient ID so per-user ordering holds without a topic explosion.
NOT this
producer.send(topic=f"user-{recipient_id}", msg) # billions of topics, broker dies
this
producer.send(topic="messages", key=recipient_id, value=msg) # fixed topics, per-user order via partition key
CG2 pushes to B. B's client acknowledges receipt. Only on that ack do you clear the message from the inbox. Ack-then-delete. Never delete-then-hope, because delete-then-hope is how you drop messages. Say B's socket died halfway through delivery: the message is still sitting in the inbox, so it goes out again on reconnect. The client deduplicates on message_id, so B never sees the double.

That is delivery. It is maybe a third of the work.
Presence is not a message, so stop putting it on the message path
Here is the thing nobody gives enough room to. Presence looks trivial and is the sneakiest scaling problem in the design.
The naive version: every client sends a heartbeat every few seconds so the server knows it is alive. Fine. Now count it. Heartbeat traffic is every online user, several times a minute, forever. That volume dwarfs actual messages by an order of magnitude, easily. Route heartbeats through your message infrastructure and your chats are now sharing a pipe with a firehose of "still here, still here, still here".
So you split it. Presence gets its own path and its own store. Each heartbeat just refreshes a key with a short TTL.
SET presence:{user_id} online EX 30 # heartbeat resets the clock
no heartbeat for 30s -> key expires -> user is "offline". no cron, no sweeper.
Online is "the key exists". Offline is "it expired". Last-seen is the timestamp you wrote. No background job hunting for dead users, the TTL does it for free.
Now the bit almost every write-up skips. And honestly, it's the tell, the thing that separates people who've actually lived in WhatsApp from people who've only drawn it on a whiteboard. Here's what I mean: as far as anyone outside the company can tell, WhatsApp doesn't spray your presence out to your whole contact list. Why would it. It sends those updates only to people who have your one-to-one chat open right this second. Which, when you sit with it, is obvious. Nobody's got your chat open, so nobody needs to see your "typing…" or your "online". And that one scoping decision, small as it looks, is the whole difference between a fan-out that melts and one that's basically free.
Receipts are just messages flowing the other way
Sent, delivered, read. Three states, three separate acks, all travelling back along the machinery you already built, just in reverse.
Sent (one grey tick) is CG1 acknowledging it has the message. Delivered (two grey ticks) is B's device acknowledging receipt, that ack routed back to A the same way any message routes. Read (two blue ticks) is B's client telling you the chat was actually opened, sent as its own event.
A blue tick is not a feature. It is a message pointed the other way.
Once you see receipts as tiny acks riding the existing rails, "where does status live" answers itself. The current state hangs off the message record, updated as each ack arrives. You do not need a separate service, you need one more field and the discipline to update it on the right event.
So here's the takeaway: delivery, presence, and receipts are three different traffic problems. The skill is knowing when to stop treating them as one.
The corrections that get you the senior nod
Two things separate a passing answer from a strong one.
First, say the Kafka correction out loud before the interviewer has to. Not a topic per user. Shared, partitioned topics. It signals you have felt this pain.
Second, know when fan-out flips. For a normal chat, write-fan-out (drop the message into each recipient's inbox on send) is fine. For a big group, writing into thousands of inboxes on every single message is madness. Sit with the number for a second. One person taps send in a 1,000-person group, and that's 1,000 separate writes. So at some point, call it around 500 members, you just stop. You flip the whole thing inside out to read-fan-out: write the message once, against the group, and let each member come pull it when they actually open the chat. WhatsApp caps group size precisely because unbounded fan-out has no happy ending.
The four questions that draw the architecture for you
Before you draw anything, pin these four things in this exact order. What is the core loop, stated as one send and one receive, before any transport is chosen. Where does the message physically wait when the recipient is offline, because "in Kafka" is not an answer, a queue is not a store. What is the actual traffic mix, because the highest-QPS (queries per second) thing in a chat app is not chats, it is heartbeats and receipts, and each deserves its own path. And what do you delete, and on which event, because ack-then-delete is the line between reliable and lossy.
Answer those four and the architecture mostly draws itself.
What this leaves out, on purpose
Now the honest edges. I have said nothing about end-to-end encryption internals, and you should defer it too unless asked, it is a talk on its own. Same for voice and video.
The genuinely hard extension, the one to name rather than wave at, is multi-device sync: the moment one account holds four active sockets, "which server holds the user" becomes "which servers", every ack fans out to all of them, and read state has to reconcile across devices that were online at different times.
Start simple. Pin the loop, keep presence off the message path, treat receipts as reverse acks, and never, ever give every user their own topic. Reach for multi-device only when someone makes you, because that is the part with no tidy ending.
What's the first box you'll draw the next time an interviewer says "design WhatsApp"?
If this kind of first-principles system design thinking helps you in interviews, follow Beyond Localhost. We publish for engineers who want to stop memorising and start reasoning.
If you're preparing for a system design interview, or just trying to survive your next production outage, followPracHubfor more engineering deep dives.
Related Articles
I Pinned Our Autoscaler for a Month to See What Would Break. Nothing Did.
Learn when Kubernetes autoscaling helps, when CPU-based HPA wastes money, and how capacity planning can cut cloud costs safely.
One Line of Code Halved Our Storage. Another Stopped Us Re-Encoding Two-Hour Films.
Learn how to scale video transcoding with segment-level retries, CMAF packaging, CDN caching, and VOD system design tradeoffs.
Why Your Ride-Sharing Database Fails at 250,000 Writes a Second
Design nearest-driver matching with geohash, Redis GEO, H3, hot/cold paths, atomic ride claims, and surge pricing for interviews.
"Just Make It Idempotent" Is Half an Answer in a System Design Interview
Learn idempotency for system design interviews: payment retries, duplicate requests, database constraints, outbox patterns, and exactly-once myths.
Comments (0)