Design WhatsApp — Real-Time Messaging, Delivery Receipts, and Presence at Scale
A system design walkthrough of WhatsApp: WebSocket delivery, message ordering, delivery and read receipts, online presence, and group fan-out at scale.
- Author
- Randhir Jassal
- Published
- Reading time
- 14 min read
- Views
- 9 views
"Design WhatsApp" flips the usual system-design instinct on its head. Every other design so far has been request/response — a client asks, a server answers, done. Chat isn't like that: the server has to push a message to a recipient who never asked for it, over a connection that's been sitting idle, possibly to a phone that's currently offline, and it has to arrive once, in order, and report back the two grey ticks and the blue ones. The whole interview is about how you hold hundreds of millions of open connections and route a message from one to another.
A chat system delivers messages between users in real time: one-to-one and in groups, with delivery and read receipts, online/last-seen presence, and reliable offline delivery. It's a push system built on persistent connections, and the hard parts are all consequences of that: which server holds the recipient's connection, how does a message get there, what happens when they're offline, and how does everyone agree on message order? (New to the method? Start with the framework.)
TL;DR — the design at a glance
| Concern | Decision |
|---|---|
| Transport | Persistent WebSocket per client to a connection server |
| Routing | Connection registry (userId → server) + a backplane (pub/sub) between servers |
| Offline | Store-and-forward — persist to the recipient's inbox, deliver on reconnect |
| Ordering | Per-chat sequence number assigned by the server |
| Receipts | sent → delivered → read, each an ack flowing back to the sender |
| Delivery | At-least-once + client dedup by clientMsgId |
- Chat is a push over persistent connections problem, not request/response.
- Each client holds one WebSocket to a connection server; a registry (Redis) tracks which server holds whom.
- To deliver A→B, look up B's server and route the message there over a backplane (Redis pub/sub or Kafka); that server pushes it down B's socket.
- Offline = store-and-forward: persist to B's inbox, deliver (and dedup) when B reconnects.
- Ordering is a per-chat sequence number the server assigns, so every participant sees the same order regardless of network races.
- Receipts (sent/delivered/read — the ticks) are just acks flowing back: server-received, device-received, chat-opened.
- Presence (online/last-seen/typing) is cheap per user but expensive to fan out — throttle it and only push to open chats.
- Groups fan out to members; cap group size so a "group celebrity" doesn't melt the fan-out (the news-feed problem again).
The one mental shift: in every request/response system the client drives; in chat, the server drives — it pushes to a recipient who isn't asking. That single inversion is why you need persistent connections, a registry to find them, a backplane to route between them, and an inbox for when they're gone. Everything else is receipts and ordering on top.
Step 1 — Requirements & scope
Functional:
- 1:1 and group messaging, real time.
- Delivery + read receipts (sent / delivered / read — the ticks).
- Presence: online, last-seen, typing indicators.
- Offline delivery — messages arrive when the recipient reconnects.
- Message history.
Parked: voice/video calls, media transfer internals, stories/status, and the internals of end-to-end encryption (noted below).
Non-functional:
- Real-time — sub-second delivery when both parties are online.
- Reliable — no lost messages; at-least-once with client dedup.
- Ordered — messages appear in a consistent order per chat.
- Massive scale — tens of billions of messages/day, hundreds of millions of concurrent connections.
Step 2 — Back-of-envelope estimates
Say 500M daily users, ~40 messages each per day:
Messages: 500M × 40 / 86,400 s ≈ ~230,000 messages/sec (peak ~1M/sec)
Concurrent connections: hundreds of millions online at once
→ at ~100k sockets/connection-server, that's thousands of connection servers
Storage: 20B msgs/day × ~300 bytes ≈ ~6 TB/day (before dedupe/media)
Two design drivers fall out: you need thousands of connection servers to hold that many open sockets, and a routing layer so a message on server A reaches a recipient parked on server B.
Step 3 — API & protocol
Over the persistent socket, messages are small framed events:
client → server SEND { chatId, clientMsgId, body }
server → client DELIVER { chatId, msgId, seq, from, body, sentAt }
client → server ACK { msgId, type: delivered | read }
client → server TYPING { chatId }
server → client PRESENCE { userId, state: online | offline | typing, lastSeen }
History is a normal paginated REST read: GET /chats/{chatId}/messages?cursor=.... The clientMsgId is the client's idempotency key; the server's seq is the ordering authority.
Step 4 — Data model
messages msgId, chatId, seq, senderId, body, createdAt, status
chats chatId, type (1:1 | group), memberIds
inbox userId -> [undelivered msgId, ...] (store-and-forward)
sessions userId -> connectionServerId (the connection registry, in Redis)
- Messages are sharded by
chatId(so a chat's history and sequence live together). - Inbox holds undelivered messages per user until their device acks delivery.
- Sessions is the live registry mapping each online user to the server holding their socket.
Step 5 — High-level architecture
User A ══(WebSocket)══ [ Connection server 1 ]
│ SEND
▼
[ Message service ] ── persist (seq++) ── [ Message store ]
│ look up B in [ Session registry (Redis) ]
▼
[ Backplane: pub/sub or Kafka ]
│ route to B's server
▼
User B ══(WebSocket)══ [ Connection server 2 ] ── push DELIVER ──▶ B
│ (B offline?) → write to B's inbox, deliver on reconnect
The connection servers hold the sockets; the message service persists and assigns order; the session registry finds the recipient's server; the backplane carries the message between servers. That's the whole spine.
Step 6 — Deep-dive: the hard parts
Persistent connections & routing
Each client keeps one WebSocket open to a connection server (through a load balancer that supports sticky, long-lived connections). The session registry (userId → serverId, in Redis) records where each user is parked. To deliver A→B: the message service looks up B's server and publishes the message onto the backplane; B's connection server is subscribed, receives it, and pushes it down B's socket. When a connection drops, the client reconnects (to possibly a different server) and the registry is updated.
Ordering — one sequence per chat
Network races mean two messages can arrive at the server "at the same time." Don't trust client clocks. The server assigns a monotonic seq per chat at persist time; every participant orders by seq, so everyone sees the identical order. The client's clientMsgId handles the other direction — dedup, so a retried send doesn't create a duplicate.
Delivery & read receipts (the ticks)
Three states, three acks:
- Sent (one grey tick) — the server persisted the message.
- Delivered (two grey ticks) — the recipient's device received it and sent a
deliveredack. - Read (two blue ticks) — the recipient opened the chat and sent a
readack.
Each ack routes back to the sender the same way messages do (registry → backplane → sender's socket), updating the message's status.
Presence (online / last-seen / typing)
Presence is driven by connection state + heartbeats: an active socket means "online"; the last heartbeat timestamp is "last seen"; typing is an ephemeral event. The trap is fan-out — pushing every presence change to everyone who might care is enormous. Mitigate: only send presence to users with the chat open, and throttle (typing events especially). Presence is cheap to track and expensive to broadcast.
Offline delivery — store-and-forward
If B has no active session, the message is written to B's inbox (persistent). When B reconnects, it drains the inbox in order, acking each as delivered. Because delivery is at-least-once, B's client dedups by msgId — a message delivered twice (e.g., ack lost) is shown once.
Group messaging
A group message fans out to each member: for every member, route to their connection server if online, else write to their inbox. Small groups are cheap; very large groups reintroduce the celebrity/fan-out problem from the news-feed design, which is exactly why real chat apps cap group size.
Step 7 — Bottlenecks & scaling
- Connection servers scale horizontally; each holds ~100k sockets. Use a WebSocket-aware load balancer.
- The backplane (Redis pub/sub or Kafka) must sustain the full message rate; partition by chat/user.
- Presence fan-out is the sneaky cost — throttle and scope it.
- Reconnection storms: when a connection server dies, all its clients reconnect at once — stagger with jittered backoff so you don't thundering-herd the survivors.
- Storage: WhatsApp-style systems can delete messages after delivery (privacy + cost); others persist history sharded by
chatId.
Step 8 — Trade-offs & wrap-up
- WebSocket vs long-polling: persistent sockets are efficient and truly real-time; long-polling is simpler but wasteful. At this scale, WebSocket.
- Store-and-forward vs store-forever: delete-after-delivery is cheaper and more private; keeping history server-side enables multi-device sync and search. Pick per product.
- At-least-once + dedup vs exactly-once: exactly-once across flaky mobile networks is a fantasy; at-least-once with
clientMsgId/msgIddedup is the real target. - Presence accuracy vs cost: real-time presence for everyone is too expensive; scope and throttle it.
- End-to-end encryption: if messages are E2E-encrypted, the server routes ciphertext it can't read — which rules out server-side search and means receipts/ordering must work on opaque blobs.
The design checklist
- Persistent WebSockets to connection servers; sticky, long-lived.
- Session registry (
userId → server) in Redis. - Backplane (pub/sub / Kafka) to route between connection servers.
- Store-and-forward inbox for offline recipients.
- Per-chat
seqfor ordering;clientMsgId/msgIdfor dedup. - Receipts as acks (sent/delivered/read) routed back to the sender.
- Presence via heartbeats, scoped and throttled on fan-out.
- Group fan-out with a size cap; reconnection-storm handling.
The honest stuff: caveats and when it's overkill
- A small app doesn't need connection servers and a backplane. A single WebSocket/SignalR server (or even long-polling) handles thousands of users. Build the routing spine only when one server can't hold the connections.
- Exactly-once delivery is a myth on mobile. Networks drop, apps background, acks vanish. Design at-least-once and dedup on the client — don't promise exactly-once.
- Presence fan-out will bankrupt you if unscoped. Broadcasting every "typing…" to every contact is an N² firehose. Scope to open chats and throttle aggressively.
- Ordering can't trust client clocks. Phones have skewed, user-settable clocks. The server's per-chat sequence is the only order everyone can agree on.
- Big groups are the celebrity problem in disguise. Fanning out to a 100k-member group per message is a write storm — cap group size or the design falls over.
- Reconnection storms are self-inflicted DDoS. A dead connection server dumps its clients onto the survivors all at once; without jittered backoff you cascade the failure.
- E2E encryption removes server features. If you can't read the payload, you can't search it, moderate it, or generate previews server-side. That's a product decision, not just a crypto one.
In production at Mattrx
Mattrx isn't a chat app, but its real-time layer is built on exactly these primitives. Two features need the server to push: the live campaign dashboard (activity, KPI ticks, and "who else is viewing this campaign" presence) and Mattrx Insights, the agentic AI assistant that streams its answer as it thinks. The first version polled the API every ten seconds for updates and ran Insights as a plain request/response — so dashboards lagged reality and users stared at a spinner for the whole ~4.2s an agentic answer took. We rebuilt both on a single SignalR hub with a Redis backplane, so any of the N hub servers can reach any client, exactly like the connection-server + registry + backplane spine above.
| Metric | Before | After |
|---|---|---|
| Live campaign updates | ~10s HTTP polling (stale, wasteful) | SignalR push, sub-second |
| Insights AI answer | request/response spinner (~4.2s to full answer) | streamed; first token p95 ~300ms, full answer p95 1.8s |
| Collaboration presence | none | live "who's viewing this campaign" |
| Transport | repeated polls per client | one persistent WebSocket + Redis backplane across servers |
| Reconnect behaviour | full page reload | resume connection + replay missed events |
The dashboard stopped lying about the present, and Insights went from "wait, then read" to "watch it answer" — with presence and reconnection falling out of the same connection model for free.
The architecture at Mattrx
React dashboard ══(WebSocket / SignalR)══ [ Azure App Service · .NET 9 SignalR hub × N ]
│ │
Groups = campaign rooms │ │ presence (who's viewing)
▼ ▼
[ Azure Cache for Redis · SignalR backplane + presence sets ]
▲
campaign activity / Insights tokens│
│
[ .NET 9 workers · MediatR ] ─────────┘ (Kafka activity, Insights streaming)
Production implementation (Mattrx)
Here is the core of Mattrx's real-time hub on ASP.NET Core / .NET 9: campaign "rooms" as SignalR groups, Redis-backed presence on connect/disconnect, and an IAsyncEnumerable streaming method that pushes Insights answer tokens down the socket as MediatR produces them.
using System.Runtime.CompilerServices;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using StackExchange.Redis;
namespace Mattrx.RealTime;
// Program.cs — a Redis backplane lets any of the N hub servers reach any client:
// builder.Services
// .AddSignalR()
// .AddStackExchangeRedis(cfg.GetConnectionString("Redis")!,
// o => o.Configuration.ChannelPrefix = RedisChannel.Literal("mattrx-rt"));
[Authorize]
public sealed class MattrxHub(IMediator mediator, IPresenceTracker presence) : Hub
{
// Tenant is taken from the authenticated principal, never from client args,
// so a client cannot subscribe to another tenant's campaign room.
private string TenantId => Context.User!.FindFirst("tenant_id")!.Value;
// Join a campaign room: receive its live activity + presence.
public async Task JoinCampaign(Guid campaignId)
{
var group = CampaignGroup(TenantId, campaignId);
await Groups.AddToGroupAsync(Context.ConnectionId, group);
var viewers = await presence.EnterAsync(group, Context.UserIdentifier!);
await Clients.Group(group).SendAsync("presence:viewers", viewers);
}
// Stream an Insights answer token-by-token. SignalR turns an IAsyncEnumerable
// into a client-side stream, so the React dashboard renders tokens as they land.
public IAsyncEnumerable<string> AskInsights(
Guid campaignId, string prompt, [EnumeratorCancellation] CancellationToken ct)
=> mediator.CreateStream(new InsightsQuery(TenantId, campaignId, prompt), ct);
public override async Task OnDisconnectedAsync(Exception? exception)
{
// Presence is derived from connection state: leaving drops the viewer
// from every room it was in, and peers are notified.
foreach (var group in await presence.LeaveAllAsync(Context.UserIdentifier!, Context.ConnectionId))
{
var viewers = await presence.ViewersAsync(group);
await Clients.Group(group).SendAsync("presence:viewers", viewers);
}
await base.OnDisconnectedAsync(exception);
}
private static string CampaignGroup(string tenantId, Guid campaignId)
=> $"rt:{{{tenantId}}}:campaign:{campaignId:N}";
}
// Presence tracked in Redis so it survives across the N hub servers.
public sealed class RedisPresenceTracker(IConnectionMultiplexer redis) : IPresenceTracker
{
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(2);
public async Task<long> EnterAsync(string group, string userId)
{
var db = redis.GetDatabase();
var key = ViewersKey(group);
await db.SetAddAsync(key, userId);
await db.KeyExpireAsync(key, Ttl); // heartbeat refreshes this
return await db.SetLengthAsync(key);
}
public Task<long> ViewersAsync(string group) =>
redis.GetDatabase().SetLengthAsync(ViewersKey(group));
public async Task<IReadOnlyList<string>> LeaveAllAsync(string userId, string connectionId)
{
// A per-connection index of joined groups is maintained on join (elided);
// on disconnect we remove this user from each and return the affected rooms.
var groups = await LookupJoinedGroupsAsync(connectionId);
var db = redis.GetDatabase();
foreach (var group in groups)
await db.SetRemoveAsync(ViewersKey(group), userId);
return groups;
}
private static RedisKey ViewersKey(string group) => $"presence:{group}";
}
Insights streaming rides mediator.CreateStream, so the same MediatR pipeline that governs and evaluates AI answers feeds the socket; the Redis backplane means a token produced on hub server 3 reaches a client parked on hub server 7; and presence is pure connection state — OnDisconnectedAsync cleans it up, exactly the store-nothing-you-can-derive discipline the chat design relies on.
The model to carry forward
Chat is the system where the server pushes. Once you accept that inversion, the whole design writes itself: persistent connections to hold the recipients, a registry to find which server holds whom, a backplane to route between servers, and an inbox for when the recipient is gone. Layer receipts (acks flowing back), ordering (a server-assigned per-chat sequence), and scoped presence on top, and cap your groups so fan-out stays sane. Explain why the server drives, and the follow-ups about ticks, ordering, and offline delivery all have the same shape of answer.
Three habits this problem teaches:
- Lead with "the server pushes." Naming the request/response inversion up front frames every later decision.
- Separate holding connections from routing between them. Connection servers + a registry + a backplane is the reusable spine of every real-time system.
- Make order the server's job. A per-chat sequence is the only clock everyone can trust; client timestamps are not.
Further reading
- How to Crack Any System Design Interview: A Repeatable Framework
- Design a News Feed (Twitter/Facebook) — Fan-out, Ranking & the Celebrity Problem
- Design a Notification System — Push, SMS & Email at Scale
Prepping for a system design round and want the chat-system follow-ups drilled — or the next "Design X" walked through the same way? I'm always happy to help; reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.