Skip to content
Commitwise
Menu
System Design

Design a chat app

The classic system design question, step by step, from requirements and estimates to WebSocket gateways, message storage partitioned by conversation, per-conversation ordering, delivery and read receipts, offline sync, group fan-out and push notifications, with code in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

“Design WhatsApp” (or Messenger, or Slack) is one of the most common system design questions, and a favorite for mobile engineers because half of the hard parts live on the phone: sending while offline, showing messages in the right order, knowing what was delivered and read, and catching up after a day without signal.

This walkthrough follows the interview order: requirements, estimates, the big picture, then the deep dives interviewers usually ask for.

New to some of these? Open what you need, or skip ahead.

Basics:What are clients and servers?

A server is a computer (usually many of them) that runs your backend: it stores data and answers requests. A client is whatever sends the requests: your mobile app, a website, another server.

Big systems put many identical servers behind one address, with a load balancer in front that spreads requests between them. Data lives in a database, and frequently used data is often kept in a fast cache (in memory) so the database isn’t asked every time.

Basics:What is a WebSocket?

Normal HTTP is request–response: the app asks, the server answers, and the server can’t speak until it’s asked. A WebSocket is a connection that stays open in both directions, so the server can push a new message to the app the moment it arrives. Chat, live scores and multiplayer games use them.

Part Not started

Step 1: Requirements and estimates

Before drawing anything, agree on what you’re building. A chat app can mean many things, and each feature changes the design. Ask, then write down a short list:

Functional:

  • One-to-one chats and groups (up to, say, 500 members).
  • Text messages and images.
  • Messages are delivered even if the recipient is offline, with a push notification.
  • Sent, delivered and read indicators.
  • Online / last seen status.
  • History available on several devices (phone and tablet).

Non-functional:

  • Low latency: a message should arrive in well under a second when both users are online.
  • No lost messages, and no duplicates shown.
  • Messages in a conversation appear in the same order for everyone.

Out of scope (say it out loud): voice and video calls, end-to-end encryption details, message search, channels with millions of members.

Clarify the parameters that change the architecture: group size (fan-out cost), multi-device (per-device delivery state, sync), retention (storage and cost), end-to-end encryption (server can’t read, index or moderate; per-device keys and fan-out of ciphertext), ordering scope (per conversation is enough; global is neither needed nor feasible), and delivery semantics (at-least-once with client deduplication, presented as exactly-once).

Daily active users:              100 million
Messages per user per day:       40
Messages per day:                4 billion
Average per second:              4e9 / 100,000 s  ≈ 40,000 / s
Peak (×3, evening hours):        ≈ 120,000 messages / s written
Delivered (avg 1.5 recipients,   ≈ 200,000 deliveries / s at peak, plus receipts:
  some groups):                    several hundred thousand small events / s

Message size (text + metadata):  ~200 bytes  → 4e9 × 200 B ≈ 0.8 TB / day ≈ 300 TB / year
Images: stored in object storage behind a CDN, not in the message store

Concurrent connections:          ~30% of DAU online at peak ≈ 30 million open WebSockets
                                 at ~100,000 per gateway server → ~300 gateway servers
  • Write-heavy: every message is a write, and most are read only a few times. This rules out designs that are only good at reads.
  • Connection-heavy: tens of millions of phones keep a connection open at the same time, which needs its own layer of servers.
  • Storage grows forever unless there’s a retention policy; images go to object storage and a CDN, never into the message database.
  • Scope: 1:1 + groups (≤500), text + media, offline delivery with push, sent/delivered/read, presence, multi-device history. Out: calls, E2EE details, search, huge channels.
  • ~40k msg/s average, ~120k peak; ~300 TB/year of text; ~30M concurrent sockets → hundreds of gateways.
  • Per-conversation ordering; at-least-once delivery with client dedup; media in object storage + CDN.

Check this part

  1. Where should images sent in chats be stored?

  2. What ordering does a chat app need?

Scope it first: one-to-one and small group chats, text and images, delivered and read receipts, online presence, offline delivery with push notifications, and history on several devices. At 100 million daily users, that's about 1 million messages per second at peak and hundreds of terabytes per year: a write-heavy, connection-heavy system.

Part Not started

Step 2: The big picture

Chat app architecture

WebSocketuploadPhonesGatewaysWebSocket serversChat serviceMessage storepartitioned by conversationRoutingwho is connected wherePush serviceAPNs / FCMObject storage + CDN

Tap a box to see what it does.

Follow one message

  1. Ana types “Dinner at 7?” The app stores it locally with a client id (a UUID) and status “sending”, shows it immediately, and sends it over the WebSocket.
  2. The gateway forwards it to the chat service, which checks the client id (already stored? return the existing message, no duplicate), assigns the next sequence number in this conversation (say 1,042), and writes it to the message store.
  3. The chat service acknowledges to Ana’s app: client id → server id and sequence 1,042. The app marks it sent (one check mark).
  4. It looks up Marko’s devices in the routing layer. His phone is connected to gateway 17, so the message is pushed there and down his socket.
  5. Marko’s app stores it and sends a delivered receipt (two check marks for Ana). When he opens the chat, a read receipt follows.
  6. If Marko were offline, step 4 would send a push notification instead, and his app would fetch everything after the last sequence it has when it reconnects.
  • Gateways (WebSocket termination, auth, thin) → chat service (dedup by client id, per-conversation sequence, persist, route) → store partitioned by conversation, clustered by seq.
  • Routing via a connection registry (user/device → gateway) or per-user pub/sub; push (APNs/FCM) for offline devices; sync by sequence cursor on reconnect.
  • Media via presigned upload to object storage + CDN; messages carry references.

Check this part

  1. Why are messages stored partitioned by conversation?

  2. What happens when the recipient isn't connected?

Phones keep a WebSocket to a fleet of gateway servers. The chat service assigns each message a per-conversation sequence number, stores it in a database partitioned by conversation, and routes it to the recipients' gateways through a connection registry or pub/sub; offline recipients get a push notification and fetch on reconnect.

Part Not started

Step 3: Ordering, duplicates and catching up

Three things go wrong in naive chat apps: messages appear in the wrong order, the same message appears twice, and messages sent while you were offline never show up. One idea fixes all three: the server numbers every message in a conversation, 1, 2, 3…, and the app keeps track of the last number it has.

Ordering: a per-conversation monotonic sequence assigned by the single writer for that conversation (the partition owner, a conditional write on a counter, or a lightweight transaction) gives a total order per conversation and gap detection for clients. Delivery: the client’s outbox retries until an ack, with a client-generated id as the idempotency key, so the server stores each message once. Sync: devices keep a cursor (last contiguous seq per conversation, or a per-user inbox offset) and request deltas on reconnect; live pushes that arrive with a gap trigger a fetch of the missing range. Receipts are themselves small events with the highest seq delivered/read, which collapses many receipts into one.

struct OutgoingMessage: Codable {
    let clientId: UUID                 // created once, reused on every retry
    let conversationId: String
    let text: String
}

actor ChatSync {
    private var lastSeq: [String: Int] = [:]   // per conversation, persisted locally

    func send(_ message: OutgoingMessage) async throws {
        try await outbox.save(message)                    // survives app restarts
        try await socket.send(.message(message))          // retried until acked
    }

    func onReconnect(conversations: [String]) async throws {
        for id in conversations {
            let missing = try await api.messages(conversationId: id, after: lastSeq[id] ?? 0)
            try await store.insert(missing)
            if let last = missing.last { lastSeq[id] = last.seq }
        }
    }
}
@Serializable
data class OutgoingMessage(
    val clientId: String = UUID.randomUUID().toString(),   // created once, reused on every retry
    val conversationId: String,
    val text: String,
)

class ChatSync(private val api: ChatApi, private val dao: MessageDao, private val outbox: OutboxDao) {
    suspend fun send(message: OutgoingMessage) {
        outbox.insert(message)                              // survives process death
        socket.send(Frame.Message(message))                 // retried until acked
    }

    suspend fun onReconnect(conversationIds: List<String>) {
        for (id in conversationIds) {
            val after = dao.lastSeq(id) ?: 0
            dao.insertAll(api.messages(conversationId = id, after = after))
        }
    }
}
public record OutgoingMessage(String clientId, String conversationId, String text) {
    public static OutgoingMessage create(String conversationId, String text) {
        return new OutgoingMessage(UUID.randomUUID().toString(), conversationId, text); // reused on retries
    }
}

public final class ChatSync {
    public void send(OutgoingMessage message) {
        outbox.save(message);                               // survives restarts
        socket.send(Frame.message(message));                // retried until acked
    }

    public void onReconnect(List<String> conversationIds) {
        for (String id : conversationIds) {
            long after = store.lastSeq(id).orElse(0L);
            store.insertAll(api.messages(id, after));
        }
    }
}
interface OutgoingMessage { clientId: string; conversationId: string; text: string }

class ChatSync {
  async send(message: OutgoingMessage): Promise<void> {
    await this.outbox.save(message);                        // survives reloads (IndexedDB)
    this.socket.send({ type: "message", message });         // retried until acked
  }

  async onReconnect(conversationIds: string[]): Promise<void> {
    for (const id of conversationIds) {
      const after = (await this.store.lastSeq(id)) ?? 0;
      await this.store.insertAll(await this.api.messages(id, after));
    }
  }
}
suspend fun accept(senderId: String, msg: OutgoingMessage): StoredMessage {
    messages.findByClientId(msg.conversationId, msg.clientId)?.let { return it }   // a retry: same answer
    val seq = sequences.next(msg.conversationId)        // atomic per conversation
    val stored = StoredMessage(msg.conversationId, seq, msg.clientId, senderId, msg.text, Instant.now())
    messages.insert(stored)
    router.deliver(stored)                              // to recipients' gateways, or push
    return stored
}
public StoredMessage accept(String senderId, OutgoingMessage msg) {
    Optional<StoredMessage> existing = messages.findByClientId(msg.conversationId(), msg.clientId());
    if (existing.isPresent()) return existing.get();                       // a retry: same answer
    long seq = sequences.next(msg.conversationId());                       // atomic per conversation
    StoredMessage stored = new StoredMessage(msg.conversationId(), seq, msg.clientId(),
            senderId, msg.text(), Instant.now());
    messages.insert(stored);
    router.deliver(stored);
    return stored;
}
async function accept(senderId: string, msg: OutgoingMessage): Promise<StoredMessage> {
  const existing = await messages.findByClientId(msg.conversationId, msg.clientId);
  if (existing) return existing;                                         // a retry: same answer
  const seq = await sequences.next(msg.conversationId);                  // atomic per conversation
  const stored = { ...msg, seq, senderId, sentAt: new Date() };
  await messages.insert(stored);
  await router.deliver(stored);
  return stored;
}
  • Client id: the app creates it once. If the connection drops before the acknowledgement, the app sends the same message again with the same id, and the server returns the stored one instead of creating a duplicate.
  • Sequence number: the server’s numbering decides the order everyone sees.
  • Catching up: on reconnect, the app asks “everything after 1,041”. It doesn’t matter how long it was offline, or whether some pushes were lost.
  • Gaps: if a live message arrives with number 1,045 but the app has 1,042, it fetches 1,043 and 1,044.
Spot the bugAnswers before questions

Tap the line with the bug.

struct Message { let text: String; let sentAt: Date }   // sentAt from the sender's phone

func sorted(_ messages: [Message]) -> [Message] {
    messages.sorted { $0.sentAt < $1.sentAt }
}
data class Message(val text: String, val sentAt: Instant)   // sentAt from the sender's phone

fun sorted(messages: List<Message>) = messages.sortedBy { it.sentAt }
record Message(String text, Instant sentAt) {}   // sentAt from the sender's phone

List<Message> sorted(List<Message> messages) {
    return messages.stream().sorted(Comparator.comparing(Message::sentAt)).toList();
}
interface Message { text: string; sentAt: Date }   // sentAt from the sender's phone

function sorted(messages: Message[]): Message[] {
  return [...messages].sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
}

Predict the output

Ana sends a message on a train. The server stores it and sends the acknowledgement, but the connection drops before the ack arrives. The app reconnects and resends the message from its outbox. How many copies does Marko see?

Exercisemedium

Read receipts without flooding the server

In a group of 200 people, every member’s app sends a “read” receipt for every message it displays. A busy group gets 30 messages a minute. How many receipts is that, and how would you make it cheaper while still showing “read by” correctly?

›

A receipt for message 1,050 implies all earlier messages in that conversation were read too. What’s the minimum a device needs to report?

SolutionTry it yourself first

Naively: 200 members × 30 messages = 6,000 receipts per minute for one group, each fanned out to 199 other members: over a million events per minute.

Cheaper:

  • Report a watermark, not each message: a device sends “read up to seq 1,050” in this conversation. One small write replaces dozens of receipts, and it’s idempotent (a lower value never overwrites a higher one).
  • Batch and debounce on the device: send the watermark when the user leaves the chat or every few seconds while reading, not per message.
  • Store per member, compute on demand: keep (conversation, member) → read_seq. “Read by” for a message is the members whose read_seq ≥ message.seq, computed when someone opens the info screen.
  • Fan out less: in big groups, don’t push every member’s receipt to everyone; push receipts only to the message’s sender, and let others fetch the details when they ask.

Production puzzles

Spot the bugMessages that only arrive sometimes

Tap the line with the bug.

// Runs in every gateway instance
private val connections = ConcurrentHashMap<String, WebSocketSession>()   // userId → socket

fun onConnect(userId: String, session: WebSocketSession) { connections[userId] = session }

fun deliver(message: StoredMessage, recipientId: String) {
    connections[recipientId]?.send(message.toFrame())
        ?: pushService.notify(recipientId, message)
}
// Runs in every gateway instance
private final Map<String, WebSocketSession> connections = new ConcurrentHashMap<>();  // userId → socket

void onConnect(String userId, WebSocketSession session) { connections.put(userId, session); }

void deliver(StoredMessage message, String recipientId) {
    WebSocketSession s = connections.get(recipientId);
    if (s != null) s.send(message.toFrame());
    else pushService.notify(recipientId, message);
}
// Runs in every gateway instance
const connections = new Map<string, WebSocket>();   // userId → socket

function onConnect(userId: string, ws: WebSocket) { connections.set(userId, ws); }

function deliver(message: StoredMessage, recipientId: string) {
  const ws = connections.get(recipientId);
  if (ws) ws.send(JSON.stringify(message));
  else pushService.notify(recipientId, message);
}

Groups and fan-out

  • Small groups (≤ a few hundred): fan-out on write: the chat service delivers to each member’s devices and updates per-member unread counters. Cost grows with members × messages.
  • Large groups and channels: fan-out on read: store once, push only a lightweight “new activity” signal (or nothing) and let clients fetch the conversation; skip per-member receipts.
  • Hot conversations: one conversation is one partition and one sequence counter, so a very busy group concentrates load; shard the sequencer by conversation id across chat-service instances (consistent hashing), and batch writes.
  • Multi-device: delivery and read state per device; a read on one device clears notifications on the others with a small sync event.
  • Server-assigned per-conversation sequence for order and gap detection; client clocks for display only.
  • Client outbox with client-generated ids, retried until acked; server dedups by (conversation, clientId).
  • Sync by cursor (last seq) on reconnect; receipts as per-member watermarks, batched.
  • Cluster-wide routing: connection registry or per-user pub/sub; live delivery is best-effort, storage + sync is the guarantee.
  • Fan-out on write for small groups, on read for large ones; hot conversations need sequencer sharding and batching.

Check this part

  1. What should decide the order of messages in a chat?

  2. How does the server avoid storing a resent message twice?

  3. Why can't a gateway route messages using only its own in-memory connection map?

Order messages by a server-assigned per-conversation sequence number, never by phone clocks. Clients send with a unique id and retry until acknowledged; the server deduplicates by that id. On reconnect, clients ask for everything after the last sequence they have, which also fills any gaps.

Key takeaways

  • Agree on scope and scale first: chat is write-heavy and keeps millions of connections open.
  • Gateways hold WebSockets; a chat service numbers, stores and routes messages; images go to object storage and a CDN.
  • The server numbers messages per conversation; that number decides the order and lets apps catch up after being offline.
  • Apps send with a unique id and retry; the server ignores duplicates; read receipts are “read up to” watermarks.
  • Partition messages by conversation, cluster by sequence; dedup by client id.
  • Live delivery via registry or pub/sub is best-effort; persistence plus cursor sync guarantees completeness.
  • Choose fan-out per group size; collapse receipts into watermarks.
  • Multi-device and hot conversations are where designs break; plan for both.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.