Skip to main content

Command Palette

Search for a command to run...

Design a Messaging App | System Design Question

Updated
23 min readView as Markdown

Design a messaging appis the system design question where "real time" stops being a buzzword and becomes an actual engineering constraint. Every other design lets the server answer when the client asks. Here the server has to push - a message that lands on your phone was never requested by your phone. That one difference drives the whole design: the protocol, the server's statefulness, where message history lives, and how a green dot next to a name stays accurate.

We will walk it end to end on a 60-minute budget, the way an interview actually runs, using the 4-step framework. Roughly: 10 minutes scoping, 15 on the high-level design, 25 on the deep dive, 5 to 10 wrapping up. The clock is the point - a messaging app has enough surface to eat three hours, so the skill is choosing what to build now and what to name and defer.

Step 1: Scope it - and pin down the one word that changes everything

"Design a messaging app" is deliberately vague. The single most important thing to nail is what kind: a one-on-one messenger (WhatsApp, Messenger), a group-first office tool (Slack), or a large-room low-latency voice app (Discord). Build for the wrong one and the rest of the hour is wasted. So you ask, and you stay ready for messy answers.

A realistic exchange, punts and curveballs included:

You: Are we focused on one-on-one chat, group chat, or both? Interviewer: Both. One-on-one, plus small groups.

You: How big can a group get? That number changes the group design a lot. Interviewer: Cap it at 100 members.

You: What's the scale - daily active users? Interviewer: You tell me.

You: Then I'll assume 50 million daily active users and design for that - stop me if you had a very different number in mind. I'll also assume text only for now. Interviewer: That works.

You: Mobile, web, or both? And can one account be logged in on several devices at once? Interviewer: Both platforms, and yes - multi-device.

You: Do we keep chat history on the server, or is the server just a relay that forgets a message once it's delivered? That choice changes the whole storage design. Interviewer: Keep it on the server - users should get full history on a new device.

You: Which features are in scope - presence, push notifications, read receipts, attachments? Interviewer: Presence, push notifications, and read receipts - the sent/delivered/read ticks. Actually - let's also do end-to-end encryption and photo sharing.

Two things to catch there. First, the storage question is the fork this whole post hangs on: "keep history on the server" makes the server the source of truth (what we design here); "forget on delivery" is the store-and-forward model WhatsApp uses, which changes storage, multi-device, and encryption all at once. Always ask it; never assume messages are stored forever just because the textbook version does.

Second, the curveball. A less experienced interviewer will happily pile on scope. You do not design encryption and media now - you name the one-line cost of each and park it: "End-to-end encryption means keys live on the clients and the server only relays ciphertext, which also rules out server-side history - I'll get core delivery working first and fold it in if we have time. Media means large blobs, so object storage plus a CDN rather than the message store, same deal." You will come back to both in the wrap-up.

The rule underneath the whole exchange: when the interviewer gives a number, use it; when they punt ("you tell me"), assume a reasonable value, say it out loud, and write it down; when they bolt on scope, name its cost and keep moving. Never stall waiting for a tidy answer. If which requirements are functional versus non-functional still feels fuzzy, that post drills it - here is the split for this problem, as precise bullets, not prose:

Functional requirements

  • One-on-one chat with low delivery latency.
  • Small group chat, capped at 100 members.
  • Online presence (the green dot) for a user's contacts.
  • Multi-device support - the same account logged in on several devices, all kept in sync.
  • Push notifications when the recipient is offline or the app is backgrounded.
  • Delivery and read receipts - per message, show sent, then delivered, then read.
  • Text messages only, up to 100,000 characters each. Server keeps history (our chosen fork - the store-and-forward alternative is the WhatsApp post).
  • End-to-end encryption and media attachments: acknowledged, deferred.

Non-functional requirements

  • Low latency - a sent message should reach an online recipient in well under a second.
  • High availability - messaging is the critical path; target 99.9%+.
  • Durability - a delivered message is never silently lost, even across disconnects.
  • Consistency of ordering - messages in a conversation arrive in the order they were sent.
  • Scale to 50 million daily active users and the message volume that implies.

Back-of-the-envelope estimate

A few numbers so every later decision has something to point at. The full method is in back-of-the-envelope estimation; here is the short version.

  • Messages per day. Say each of the 50M daily users sends 40 messages a day. That's 50M x 40 = 2 billion messages/day. (For reference, Messenger and WhatsApp together reportedly process around 60 billion a day - our slice is smaller but the same shape.)
  • Write throughput. 2B / 86,400 s is roughly 23,000 messages/second on average, and you should design headroom for a few times that at peak - call it ~100k/s peak.
  • Storage. Take an average stored message at ~300 bytes (text plus metadata: ids, timestamps, sender, channel). 2B x 300 bytes = 600 GB/day, about 210 TB/year, and because we chose to keep history it grows without bound. That number - hundreds of TB and climbing - is the one that decides the database. Hold onto it. (Note how directly the Step 1 storage fork drives this: a store-and-forward app that deletes on delivery never accumulates it.)
  • Concurrent connections. If even 10% of daily users are connected at once, that's 5 million live, persistent connections the servers must hold open simultaneously. That is the number that makes the chat server special.

Two numbers now steer everything: hundreds of TB of ever-growing history (a storage problem) and millions of always-open connections (a connection-management problem). Keep both in view.

Step 2: High-level design

I'll build this up one decision at a time from those numbers, not draw a finished diagram and back-fill reasons. Three questions, in order: how do client and server talk, what services do we need, and where does the data live.

The protocol: how does the server push?

For most web apps the client asks and the server answers - request/response over HTTP. The sender side of chat fits that fine: User A opens an HTTP connection and posts a message to the service. Plenty of chat apps started exactly there.

The hard side is the receiver. HTTP is client-initiated - the server has no way to ring the client and say "you have a message." Three techniques get used to fake a server-initiated push, and being able to compare them cold is a common interview checkpoint:

  • Polling. The client asks "anything new?" every few seconds. Simple, but wasteful - most answers are "no," and you trade latency (poll less often) against load (poll more often). At millions of clients this is a lot of pointless traffic.
  • Long polling. The client asks, and the server holds the request open until a message actually arrives or a timeout fires; then the client immediately re-asks. Better, but awkward: HTTP servers are usually stateless, so the server holding your open request may not be the one that receives the message meant for you; the server can't easily tell a disconnected client from a quiet one; and idle users still reconnect on every timeout.
  • WebSocket. A single connection, opened by the client, that starts life as HTTP and is then upgraded to a persistent, bidirectional channel. Once up, either side can send at any time with no new handshake. It rides ports 80 and 443, so firewalls generally leave it alone.

WebSocket wins for the receiver, and since it is bidirectional there is no reason not to use it for the sender too - one connection type for both directions simplifies the client and the server. Everything that isn't real-time (signup, login, loading your profile, uploading an avatar) stays on ordinary HTTP request/response. Use the right tool for each path.

WebSocket upgrade: an HTTP request is upgraded to one persistent bidirectional connection over which the server can push messages at any time

The WebSocket protocol is the backbone here. It has one consequence that shapes the entire architecture: those connections are persistent and stateful. A server holding your live socket is not interchangeable with any other server - and at our scale it is holding millions of them.

Stateless services vs the one stateful service

That splits the backend cleanly into two kinds of service.

Stateless services handle everything request/response: login, signup, user profile, friends list, settings. They sit behind a load balancer that routes by path, they hold no per-user connection, and any instance can serve any request - so you scale them the boring, easy way, by adding boxes behind the balancer. This is the same horizontal-scaling playbook for backends: keep the instances stateless and let the load balancer spread traffic.

The stateful service is the chat service - the fleet of chat servers that each hold a pile of live WebSocket connections. It is stateful because a client stays pinned to one chat server for the life of its session. This is the service the whole problem revolves around, and it's why we need a piece most CRUD apps don't: service discovery, whose job is to hand a connecting client the address of a good chat server to pin to. We'll detail it in the deep dive.

And one third-party integration matters enough to name now: push notifications. When the recipient is offline or the app is backgrounded, there is no socket to push down - you hand the message to the platform's push service (APNs for iOS, FCM for Android), which wakes the device.

Storage: does the server even keep messages?

Before picking a database, answer the question from Step 1, because it decides whether there's a big message store at all. There is a real spectrum here, and a strong candidate names it:

  • Server as source of truth (store history). The server persists every message and serves it back on demand - a new device downloads full history, server-side search works, and delivery is decoupled from storage. This is Telegram cloud chats, Slack, and Messenger. The cost is exactly the hundreds-of-TB store we estimated, kept and secured forever.
  • Store-and-forward (relay only). The server holds a message just long enough to deliver it, then deletes it; the only durable copy lives on the users' devices. Undelivered messages sit encrypted for a bounded window (WhatsApp keeps them up to 30 days, then drops them). Storage shrinks to a delivery buffer, and the privacy story is far stronger - but a fresh device gets no history, and any server-side feature that must read content (search, for one) is off the table.

We took "keep history on the server" in Step 1, so we build the first model here. The second reshapes storage, multi-device sync, and encryption together - it's the WhatsApp store-and-forward design, a genuinely different system. Saying this fork out loud, rather than assuming messages live forever, is one of the easiest ways to sound senior.

With that settled, two very different data shapes need two different stores.

Generic data - user profiles, settings, friend lists - is modest in size, relational in nature, and read constantly. A relational database (Postgres, MySQL) is the right home, made available and scalable with replication and sharding.

Chat history is the hard one, and the estimate already told us why: hundreds of TB, still growing, at ~100k writes/second. Its access pattern is peculiar too - the read-to-write ratio is about 1:1 (unlike a read-heavy URL shortener), recent messages get almost all the reads, and old messages are rarely touched but must stay reachable for search and "jump to message." Weigh two options:

  • Relational. Familiar and transactional, but it struggles precisely here. Once a single table holds hundreds of billions of rows, the indexes grow huge and random access into the cold "long tail" of old messages gets expensive. This is not what relational engines are good at.
  • Key-value / wide-column. Stores like Cassandra and HBase scale horizontally by design, give low, predictable latency, and shard cleanly on a key. Real chat systems land here: Discord stores billions of messages in Cassandra, and Messenger famously ran on HBase.

Pick the key-value store for chat history. It matches every trait: horizontal scale for the volume, low latency for delivery, and a natural key to shard on. If the interviewer wants to go deeper on that store, the whole build is in design a key-value store.

Data model. Two message tables:

  • One-on-one messages keyed by message_id. The id, not created_at, decides order - two messages can share a timestamp, so the timestamp alone can't sequence them.
  • Group messages with a composite key (channel_id, message_id). channel_id (a channel and a group are the same thing here) is the partition key, because every read in a group is scoped to that channel - so all of a group's messages live on the same shard and come back in one range scan.

Message IDs carry real weight, because they are the ordering. Two requirements: globally unique, and sortable by time (a newer message must have a larger id). AUTO_INCREMENT gives you both but is a relational feature most key-value stores lack. So either a distributed 64-bit id generator like Snowflake - time-ordered ids minted across many nodes without coordination - or, simpler, a local sequence that is only unique within one channel. Local ids are enough, because you only ever need ordering inside a single conversation, never globally. Say that trade-off out loud; it shows you noticed you don't need the harder guarantee.

Putting it together

Now the pieces have earned their place, the architecture assembles itself: clients hold a WebSocket to a chat server for real-time messaging and use plain HTTP through the load balancer for everything else; chat servers lean on an id generator, a message sync queue, and the key-value store; presence servers track the green dot; notification servers bridge to APNs and FCM; the relational DB holds profiles and friends.

High-level chat architecture: clients connect over WebSocket to stateful chat servers and over HTTP to stateless API and service-discovery servers; chat servers use an ID generator, a message sync queue, a key-value store for history, presence servers, and notification servers bridging to third-party push

A note if the interviewer pushes on it: yes, at 50M DAU you could in theory fit the connections on a few big boxes - ~5M connections at ~10 KB of memory each is ~50 GB. But a single-box design is a non-starter the moment you say "single point of failure." Start multi-server, and say why.

Step 3: Deep dive

Three areas reward going deep: how a client finds its chat server (service discovery), how a message actually flows end to end (including groups and multi-device), and how presence stays accurate. Pick based on what the interviewer leans toward; be ready for all three.

Service discovery: which chat server do I connect to?

A client can't just hit any chat server - you want it on one that is up, not overloaded, and ideally close by. Service discovery is the matchmaker. A common building block is Apache ZooKeeper, which keeps a live registry of chat servers and picks a good one per client by criteria like geography and current load. The login flow:

  1. User A logs in; the load balancer routes it to an API server.
  2. The API server authenticates the user.
  3. Service discovery picks the best chat server for A - say chat server 2 - and returns its address.
  4. User A opens a WebSocket directly to chat server 2 and stays pinned there for the session.

This is also your recovery path: if a chat server dies, its clients reconnect, hit service discovery again, and get placed on a healthy server.

One-on-one message flow

Now the core path - what happens when User A sends User B a message:

  1. A sends the message over its WebSocket to chat server 1.
  2. Chat server 1 fetches a message_id from the id generator.
  3. It drops the message onto the message sync queue.
  4. The message is persisted to the key-value store (history is durable before anyone worries about delivery).
  5. Then it branches on B's presence:
    • B is online: the message is forwarded to the chat server holding B's socket (chat server 2), which pushes it down to B.
    • B is offline: a push notification goes out via the notification servers.

One-on-one message flow: chat server 1 gets a message id, enqueues and persists the message, then either forwards it to user B's chat server for WebSocket delivery if B is online, or triggers a push notification if B is offline

Persisting before delivering is deliberate: the message is safe in the store no matter what happens to the sockets, so an offline user gets full history the moment they reconnect.

Multi-device sync

A account is logged in on a phone and a laptop at once - both need every message, and each may have seen a different amount. The clean trick: each device tracks a cur_max_message_id, the highest message id it has already received. A message is "new" for a device when its recipient is this user and its id in the store is greater than that device's cur_max_message_id. Each device independently pulls anything newer than its own watermark from the key-value store. No device has to know about the others - they just each catch up from the same durable log. That is the payoff of persisting first.

Delivery and read receipts

The ticks next to a message are not decoration - they are three distinct facts, each learned at a different point in the flow, and getting the states right is a favourite interview probe. A message walks through three states:

  • Sent - the message reached the server and got a message_id. One tick.
  • Delivered - it landed on at least one of the recipient's devices. Two ticks.
  • Read - the recipient actually opened the conversation. Two blue ticks.

Message receipt states: sent when the message reaches the server, delivered when it lands on the recipient device, read when the recipient opens the chat

The mechanism is a receipt - a tiny control message that flows backwards, from recipient to sender, over the same infrastructure that carried the original. When B's device receives the message it fires a delivered receipt; when B opens the chat it fires a read receipt. Each receipt travels the same path in reverse - into B's chat server, onto the sender's sync queue, and down A's socket (or a push, if A is offline) - and it carries the message_id it refers to, so A's client knows exactly which bubble to update. A receipt is just a tiny message with a type, so it costs almost nothing and reuses the entire delivery pipeline.

Two design points to raise before the interviewer does. Group receipts don't reduce to one tick - "delivered" means delivered to every member and "read" means read by every member, so each recipient's client sends its own receipt and the sender tracks a per-member set, marking the message read only once the whole set is in (which is why a group read receipt is slower and more expensive than a one-on-one one). And receipts are a privacy toggle: WhatsApp lets you turn read receipts off, in which case the client simply never emits the read receipt - the sender's message stays at two ticks forever. Design it as data the client chooses to send, not something the server infers.

Group chat: fan-out on write

Groups are where it gets more interesting. When A sends to a group of A, B, and C, the message is copied into each recipient's own message sync queue - think of that queue as B's inbox and C's inbox. Every client only ever has to watch one thing: its own inbox.

Group fan-out on write: user A's single message is copied into a separate inbox (message sync queue) for each other group member, and each member pulls new messages from its own inbox

This is fan-out on write: do the copying at send time so reads are trivial. It's a great fit because the group is capped at 100 - copying a message into at most 100 inboxes is cheap, and the sync logic stays dead simple. This is exactly why the group-size question in Step 1 mattered so much. If groups could hold 100,000 members, fan-out on write would be a disaster - one message spawning 100,000 copies - and you'd flip to fan-out on read: store the message once per channel and have each member's client pull from the shared channel when it opens the group. Naming that fork, and tying it back to the 100-member cap, is the move that impresses here.

Presence: the green dot

Presence looks trivial and hides two real problems: flapping, and fan-out.

Flapping. The naive design - "socket drops, mark offline; socket opens, mark online" - turns the green dot into a strobe light, because phones drop and reconnect constantly (walk through a tunnel, switch from Wi-Fi to cellular). The fix is a heartbeat: an online client sends a small "still here" ping every few seconds. The presence server marks a user offline only after it has heard nothing for a window - say 30 seconds - not on the first missed beat. Brief blips never flip the dot.

Presence heartbeat: the client sends periodic heartbeats to the presence server; only after roughly 30 seconds of silence does the server set the user offline in the key-value store

Fan-out. When A's status flips, who needs to know? A's contacts. A publish-subscribe model handles it: each friend pair has a channel, and a status change publishes to every channel A shares, so each friend's client gets the update over its own socket.

Presence fan-out: when user A's status changes, the presence server publishes to a per-friend channel (A-B, A-C, A-D) that each contact subscribes to

This is fine while friend and group counts are small. In a 100,000-member group, one status flip fires 100,000 events - untenable. The escape hatch is to stop pushing presence for big groups and instead pull on demand: compute online status only when a user opens the group or refreshes the member list. Same fan-out-on-write versus fan-out-on-read tension as messages, applied to presence.

Follow-up questions to expect

The core design is done; this is where the interviewer probes. Have crisp answers ready.

  • A chat server dies with hundreds of thousands of open sockets - what happens? Those clients detect the dead connection and reconnect; service discovery places them on healthy servers. No messages are lost because every message was persisted to the key-value store before delivery - reconnecting clients pull anything past their cur_max_message_id. Pair it with a message resend mechanism (retry with acks) so an in-flight message isn't dropped.
  • How do you guarantee ordering, and can messages arrive out of order? Ordering is carried by time-sortable message_ids, and within a channel that's all you need - the client renders by id, not arrival time, so a message that arrives late still slots into the right place.
  • Duplicate delivery? Retries mean a message can arrive twice. Make delivery idempotent by deduping on message_id at the client - drop any id it has already seen.
  • How do you shard the message store? Shard by channel_id (or the one-on-one conversation id) so an entire conversation lives on one shard and a history read is a single-shard range scan. Consistent hashing keeps the shard map stable when you add or remove nodes, so a scaling event doesn't reshuffle everything.
  • Scale to 10x - 500M DAU? The stateless tier scales out trivially behind the load balancer. The pressure is on connection capacity (more chat servers, sharper service discovery), the message store (more shards), and the sync queues. Presence fan-out becomes the first thing to break - move big groups to pull-on-demand.
  • A hot group everyone is posting in? That channel's shard becomes a hotspot. Options: dedicate capacity to hot channels, add a caching layer for the most-recent messages, and lean on the client-side cache so quiet readers aren't re-fetching. (When and where to cache is its own topic - see caching strategies.)
  • End-to-end encryption? Encrypt on the sender, decrypt on the recipient; the server only ever relays ciphertext and never holds the keys. It rules out any server-side feature that needs to read message content (server-side search, for one) - and it pushes you toward store-and-forward, since a permanent store of unreadable ciphertext buys nothing. That combination of store-and-forward plus E2E is the WhatsApp model; WhatsApp's published encryption overview documents how it works.

Wrap up

Recap in one breath: clients hold a WebSocket to a stateful chat server for real-time messaging and use ordinary HTTP for everything else; service discovery pins each client to a good server; messages get a time-sortable id, are persisted to a key-value store before delivery, then either pushed down a live socket or handed to push notifications; groups fan out on write into per-member inboxes because they're capped at 100; and presence stays sane via a heartbeat plus pub/sub fan-out.

The load-bearing ideas are worth restating because they're where the design's judgment lives: WebSocket because the server must push; a key-value store because we chose to keep history, and that history is hundreds of TB of ever-growing, time-ordered, shard-friendly data; persist-then-deliver because it makes multi-device sync and crash recovery fall out for free; and fan-out on write only because the group cap is small - the single Step 1 answer that most shaped the build.

Where it goes next: media (object storage plus a CDN, thumbnails, and compression rather than the message store), typing indicators (more presence-shaped fan-out), and a geographically distributed edge cache to cut load times. The biggest fork of all is the one we flagged in Step 1 - flipping from server-stored history to store-and-forward with end-to-end encryption, the WhatsApp model, where the server forgets messages on delivery and can't read them in the first place. That is a different enough system to be worth designing on its own. For the framework and timing that keeps a design like this inside the hour, revisit the 60-minute interview framework.

More from this blog

P

Programming, System Design and AI | Latencot

53 posts

Here, you will read all about tech and tech updates. From writing code to building AI systems, we'll talk and share about everything.