Study Notes
Note

Design a 1:1 Chat App

A real-time, offline-capable messaging client for 30M daily users — a local-first database reconciling with a store-and-forward relay over two channels.

20 min read
Mobile System Design · Chapter 04

A real-time, offline-capable messaging client for 30 million daily users — built as a local-first database that reconciles with a store-and-forward relay over two channels.

🧭
How to read this page

Sections follow a problem → the fix that solves it flow rather than the source's order. Every diagram is redrawn from scratch and colour-codes its parts by role — green servers, blue databases and writes, amber caches, purple async work, cyan connection layers. A [+] badge marks context I added beyond the source. Links in Further reading were checked live on 24 Aug 2026.

01

Pin the problem before drawing boxes#

The interview is scoping first, architecture second. Nail down exactly what "a chat app" means here, because each answer removes whole subsystems from the design.

What we're building#

A one-to-one text chat for a global audience. The backend is a pure relay: it holds a message only until the recipient receives it, then forgets it — the device, not the server, is the permanent home for history. Group chat, media, and on-device encryption are all out of scope, which lets us commit hard to a single-conversation, text-only data model.

FunctionalNon-functionalOut of scope
Recent conversations, sorted by latest-message timeScale: 30M daily active users, all network conditionsAuth & registration (assume logged in, contacts pre-loaded)
Real-time send & receive of textData integrity: stays consistent offline, queues sends for reconnectGroup chats & media sharing
Presence, typing, delivery status, read receiptsDurability: client data is the source of truth, so it must not be lostOn-device message encryption
Offline history, paginated
Push notifications for new messages
💡
Why the DAU number matters

Thirty million concurrent-ish users is the fact that forces most later decisions: it rules out chatty polling, pushes toward a lean persistent transport, and makes client-side resilience non-optional. Tie every technical choice back to scale out loud — it shows you understand that business numbers shape architecture. [+] A useful mental check: at this size, anything the client does per message per user is multiplied by billions of events a day.

Chats 5:30 12:30 10:15 08:06 + tap Contact online · typing… Message…
Figure 1 — Two screens carry the whole product: a Chat List sorted by recency, and a Conversation view with presence, bubbles, and a composer. Outgoing bubbles are blue; incoming are neutral.
02

The backend is a relay, not a mailbox#

Clients never connect to each other. Every message goes to the backend, which finds the recipient and forwards it — and if they're offline, it holds the message until they reconnect, then drops it once delivered. That store-and-forward stance mirrors how WhatsApp treats messages as transient on the server rather than permanently stored.

Sender 📱 client Backend relay 1 · store message 2 · relay to recipient (held until online, then dropped) Receiver 📱 client message message
Figure 2 — Store-and-forward: the relay decouples sender from receiver so neither has to be online at the same time.

This split creates two fundamentally different communication needs, and they don't fit the same tool:

  • Client-initiated — sending a message, syncing pending ones. A plain request/response fits perfectly; HTTP is ideal.
  • Server-initiated — delivering an incoming message, a read receipt, a presence or typing change. The client must learn of these immediately, but HTTP only lets clients ask, never lets the server volunteer.

Push notifications look like an escape hatch for the second case, but they're too slow and inconsistent for an open conversation where typing indicators and read receipts must feel instant. The whole next section is about closing that gap.

03

Getting the server to speak first#

Four techniques have evolved to let a server push to a client over HTTP infrastructure. They form a ladder from wasteful to efficient.

Short polling Long polling SSE WebSocket mostly "no" held open → data / timeout 1 conn · server→client 1 conn · full-duplex CS
Figure 3 — The push ladder. Each rung keeps a connection open longer and wastes fewer round-trips than the last.

The ladder, rung by rung#

  • Short polling — ask on a timer: "anything new?" Trivial to build, but most responses are "no," so it burns bandwidth and CPU on both ends.
  • Long polling — the server holds the request open until it has data or hits a timeout; the client immediately re-asks. Fewer wasted trips, but timeouts still churn for quiet chats, the server holding the message may not be the one connected to the recipient, and disconnects are hard to detect.
  • Server-Sent Events (SSE) — one long-lived HTTP connection over which the server streams events (kept alive with periodic comment lines). Efficient and auto-reconnecting with a Last-Event-ID to backfill, but strictly one-way, so you still need a separate channel for the client to send.
  • WebSocket — starts as HTTP, then upgrades to a persistent, full-duplex channel on the same ports (80/443), so it slips through firewalls. Either side can send text or binary at any time.
📎
TCP vs UDP, briefly [+]

WebSocket rides on TCP: a connection is established and maintained, packets arrive in order, and losses are retransmitted — reliability you want for messages. UDP fires packets independently with no ordering or delivery guarantee but far less overhead, which is why latency-tolerant-but-loss-tolerant media (voice/video) prefer it.

OptionStrengthsWeaknesses
Short pollingDead simple; works on any HTTP stack, everywhereExcessive requests; high bandwidth when there's no news
Long pollingNear real-time with fewer requests; fine for non-critical updatesFiddly timeouts; resource-heavy on servers; scales poorly
SSESingle open connection; built-in reconnect; good over HTTP/2Server→client only; needs a second channel to send; heavy at many connections
WebSocketTrue bidirectional, low-latency, low per-message overheadMore infra (LB support); persistent connections are hard to scale

Table 1 — Trade-offs for a server-initiated transport.

04

Send over HTTP, receive over WebSocket#

The design uses a hybrid: REST/HTTP for everything the client initiates (sending a message, syncing), and a WebSocket for everything the server needs to push. The WebSocket opens at app start and stays up until either side closes it.

Sender📱 Backendrelay Receiver📱 HTTPS (send) wss (push)
Figure 4 — One direction per protocol: writes go up over HTTPS, real-time events come down over wss.

Why WebSocket over SSE here#

Both could deliver server events, but a chat sends a stream of tiny, frequent messages — typing pings, read receipts — where WebSocket's lower latency and smaller per-frame overhead win. It also has more consistent mobile support and mature tooling for reconnection and keep-alive. Keeping WebSocket scoped to real-time features only (not sends) is deliberate: it sidesteps the worst production scaling traps while still giving instant delivery. Slack and Discord run this same HTTP-for-writes, socket-for-events split at scale.

⚠️
Persistent connections are expensive

Every open WebSocket consumes server memory, load-balancing them is harder than stateless HTTP, and connection recovery, state, and monitoring all need dedicated handling. Narrowing WebSocket use to real-time pushes keeps this bounded.

How the industry actually talks [+]#

The real-time protocol zoo, each built on TCP or UDP for its use case:

ProtocolShapeSeen in
WebSocketFull-duplex over one TCP connectionSlack real-time; formerly Uber
XMPPXML-based messaging + presenceWhatsApp (customized), Kik
MQTTLightweight pub/sub over TCPEarly Facebook Messenger
WebRTCP2P audio/video/data, UDP-firstDiscord voice/video
IRCChannel-based chat over TCPMozilla community
✅
Decisions locked in

HTTP + REST for client-initiated operations · WebSocket for server-pushed real-time events · WebSocket usage deliberately scoped to keep scaling manageable.

05

The endpoints and the data they move#

Client-initiated: two HTTP endpoints#

Sending a message, and syncing what was missed while away:

Authentication: Bearer <token>
POST /v1/messages
  Body: NewMessageRequest
  Response: 201 Created → NewMessageResponse

GET /v1/messages?lastSyncedMessage={id}&limit={n}
  Response: 200 OK → SyncMessagesResponse

The NewMessageRequest carries a client-generated requestId that acts as an idempotency key — if the same request is accidentally sent twice, the backend de-duplicates on it. The response's optional fromMessageRequestId lets the client match the returned Message back to the request that created it.

The data models#

Message is the central type. Its status drives which indicator the UI shows:

ModelKey fields
MessagemessageId, fromUserId, toUserId, content, status, createdAt
MessageStatusPENDING (server hasn't got it) · SYNCED (server received) · DELIVERED (reached recipient) · READ
ConversationPreviewcontact, lastMessageSummary, lastMessageTimestamp, unreadCount — the Chat List row
Userid, name, avatarUrl, lastConnectedAt
Conversationcontact, contactInfo?, unreadCount, messages[] — loaded on opening a chat
ContactInfoonlineStatus, typing

The screen-facing models (ConversationPreview, Conversation, ContactInfo) are private to the client — the backend doesn't care how messages are displayed. Because there's exactly one conversation per contact, a User.id doubles as the conversation identifier.

Real-time: the WebSocket channel#

Real-time features connect to a URI like wss://my-api.chat.com/v1/socket/updates (the ws:///wss:// scheme mirrors http/https). Every event is a small tagged JSON envelope:

{
  "type": "message_type",
  "payload": {}
}

The server pushes new_message, message_read, and contact_status_update (online / typing) events down this channel. [+] Versioning the URL (/v1/) buys room to ship breaking changes later while keeping old clients working.

06

A client that survives bad networks#

The client is a layered app following unidirectional data flow and reactive updates: a UI layer of screens and state holders, a data layer of repositories over local and remote sources, talking to three external services.

UI layer Chat List Conversation List state holder Conv state holder Navigator Data layer Conversations repo Messages repo Users repo Messages local DS Messages remote DS Real-timeupdates DS Chats DB Backend CDN Push NotificationsProvider (3rd party) Image Loader Push client
Figure 5 — The client's two layers and its three external dependencies. The real-time updates data source holds the WebSocket; the Messages remote data source does HTTP sends.

Three external services#

The Backend (HTTP + WebSocket), a CDN for images, and a third-party Push Notifications Provider that reaches users when the app is closed and shoulders the hard delivery infrastructure.

Inside the client#

The Messages Repository is the core, owning all reads and writes. It works two remote paths: HTTP sends flow through the Messages Remote DataSource, while the Real-time Updates Remote DataSource keeps the persistent WebSocket for pushes. A Users Repository handles names and avatars; the Push Notifications Client listens for provider notifications. In the UI layer, a single Image Loader centralizes CDN fetches, and the Message List renders every message in order — including failed and pending ones, with a resend affordance.

Why a relational database on the device#

Key-value stores (Android SharedPreferences, iOS UserDefaults) are built for small preference blobs, not a growing, interrelated message history. Messages are naturally relational — each belongs to a conversation, has an author, follows a fixed shape — so a relational DB fits: it runs complex history/search queries efficiently, gives ACID guarantees when many messages move at once, and lets the schema grow (reactions, replies) without disturbing existing data.

🗂️
Eviction: let the user decide

All history lives on the device, so storage must be managed — but heavy-handed auto-deletion frustrates users. WhatsApp guides people to free space; Slack's free tier trims old messages. The chosen strategy: monitor free space, and when it's low, proactively offer to delete old messages or whole conversations. Storage policy often needs product sign-off, so confirm scope with the interviewer before designing it.

07

Keeping messages in the right order#

Order is what makes a conversation legible. On mobile it's genuinely hard: network delays reorder arrivals, offline messages must slot back in on reconnect, device clocks disagree, and both history and live updates must stay consistent. Start from how a message actually flows.

Sender Backend Receiver 1 create + show now 2 NewMessageRequest (HTTP) 3assign messageId 4.1 NewMessageResponse 4.2 WS event / push 5mark delivered
Figure 6 — Optimistic send: the UI shows the message at step 1, long before the backend confirms an ID at step 4.1. Delivery to the recipient (4.2) happens in parallel.

What field do you sort by?#

The tension is visible in Figure 6: at steps 1 and 5 the client must place the message on screen, but only after step 4 does a server ID exist.

Sort byUpsideDownside
messageId (server)Consistent across clients; backend is source of truthDoesn't exist until the server responds; breaks offline sends
createdAt (client)Instant display; works offlineDevice-clock skew; client-manipulable
createdAt + clock syncTimestamp benefits, fewer ordering bugs (e.g. Meta's SPTP)Clock drift persists; complex to do accurately

Table 2 — Ordering trade-offs on the client.

✅
The hybrid rule

Acknowledged messages sort by server messageId. Messages still in flight sort by client createdAt and always sit at the bottom of the list. Clock synchronization would work but costs too much complexity for the payoff.

What the messageId has to be#

These IDs must be time-sortable — newer messages get higher IDs — and the backend is the sole generator, which keeps ordering and integrity trustworthy across millions of messages. [+] A common shape is a Snowflake-style 64-bit ID: a high-bit timestamp, a machine ID, and a per-millisecond sequence number, so IDs are globally unique and chronologically sortable without coordination. WhatsApp (id), Messenger (mid), and Slack (ts) each keep their own scheme; how to order still-pending messages is a product call.

08

Sending when the network won't cooperate#

A send can be un-sent, in flight, or failed — and the data layer models that with a separate MessageRequest type, mapped into a NewMessageRequest just before it leaves.

MessageRequestStatusMeaning
DRAFTCreated, not yet sent by the client
PENDINGQueued and ready to send
SENTHanded to the backend, no delivery ack yet
FAILEDServer reported failure, or the request timed out
📝
There is no SUCCEEDED status — by design

When the server acknowledges, the client doesn't flip a flag. It deletes the MessageRequest entirely and creates a real Message (using the returned messageId and fromMessageRequestId). So a Message is never PENDING, and a MessageRequest never "succeeds" — the UI simply merges the two collections when rendering.

The confirmation flow#

  1. The Messages Remote DataSource receives a success response.
  2. It passes the result to the Messages Repository.
  3. The repository does two DB writes: remove the temporary request, and insert a new Message with the confirmed ID.
  4. Those writes ripple reactively up through the Conversations Repository and state holder to the Conversation screen, showing "delivered." Read receipts follow the same path, triggered by a message_read WebSocket event.

When it fails#

Sometimes no response ever comes. On failure or timeout, mark the request FAILED and bump its failCount. That counter decides whether to retry with exponential backoff — 1s, 2s, 4s… — giving the network room to recover, up to a maximum retry limit. Past the limit, mark it permanently failed and let the user retry manually or delete.

Chats DB Message DAO Msg Request DAO Messages local DS Pending Msgs Provider Messages Repository Messages Scheduler Timeout Controller Messages remote DS Backend
Figure 7 — Sending gets its own machinery so the remote data source stays thin: a Scheduler batches and retries on a background thread, a Timeout Controller watches for missing acks, and a Pending Messages Provider supplies queued and failed requests.
🔍
How the big apps do offline sync

Airbnb's messaging-sync mechanism cut network calls and inconsistency for much faster inbox loads on slow networks. Facebook Messenger's "LightSpeed" rewrite introduced MSYS (Messenger Sync System), a C library that centralizes all local data, tasks, and sync behind one prioritized queue.

09

Storing it all in SQLite#

SQLite is the natural fit: a lightweight, embeddable relational engine with real query and transaction support. Regular messages live in a Messages table; un-synced sends live in a separate MessageRequests table, because those don't have a server id yet.

CREATE TABLE Users (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL, avatar_url TEXT, last_connected_at TEXT
);
CREATE TABLE Messages (
  id INTEGER PRIMARY KEY, from_user_id INTEGER NOT NULL,
  to_user_id INTEGER NOT NULL, content TEXT NOT NULL,
  status TEXT NOT NULL, created_at TEXT NOT NULL,
  FOREIGN KEY(from_user_id) REFERENCES Users(id),
  FOREIGN KEY(to_user_id) REFERENCES Users(id)
);
CREATE TABLE MessageRequests (
  request_id INTEGER PRIMARY KEY, to_user_id INTEGER NOT NULL,
  content TEXT NOT NULL, status TEXT NOT NULL,
  last_modified_at TEXT NOT NULL, last_sent_at TEXT, fail_count INTEGER,
  FOREIGN KEY(to_user_id) REFERENCES Users(id)
);
📝
Why IDs are INTEGER

A Kotlin Long / Swift Int64 maps to SQLite INTEGER. SQLite uses type affinity — any declared type containing "INT" gets INTEGER affinity and is stored in 1–8 bytes as needed.

One database file, ordered creation#

All three tables live in a single chats.db, and all CREATE TABLE statements sit in one script so Users is created before the tables that reference it. Splitting Users into its own file would break cross-file foreign keys, complicate multi-environment setup, and invite ordering failures — not worth it here.

Keeping it fast as it grows#

  • Partition large tables — e.g. per logged-in user (chats-{userId}.db) — to bound size and speed the current user's queries.
  • Index hot columns like to_user_id, accepting slightly slower writes.
  • Batch writes into single transactions to cut disk I/O.
  • Handle errors with retries for transient failures under load, and use a connection pool for concurrent access.

[+] Concrete engines: iOS uses CoreData-over-SQLite, SQLite.swift, or FMDB; Android uses Jetpack Room or SQLDelight. The repository hides the two-table split, so upper layers see one unified conversation.

📌
Always off the main thread

Every DB operation runs on a background thread — blocking the UI thread on disk I/O is the fastest way to a janky, unresponsive chat.

10

Reaching users who closed the app#

When a message arrives for someone with no active WebSocket, the backend falls back to a push notification through a third-party provider — FCM on Android, APNs on iOS.

Backend Push ProviderFCM / APNs · 3rd party Data layer Messages repo Push client(on device) HTTPS persistent TCP
Figure 8 — The OS keeps one low-power connection to FCM/APNs for all apps, so the chat app doesn't manage its own. The backend maps each user to device tokens registered at install time.

The device receives via a low-power, TLS-encrypted TCP connection the OS maintains (Google Play Services on Android; the push daemon on iOS). Handling then depends on app state:

  • Foreground — the Push Client processes it directly and asks the Messages Repository to sync.
  • Background or killed — the OS shows a tray notification; tapping it brings the app forward with the payload.
⚠️
Push is a hint, not a guarantee

FCM/APNs and power-saving modes throttle, delay, or batch delivery; users can revoke permission; the two platforms differ; deep-linking to the right screen and restoring state is fiddly; and the async nature makes bugs hard to debug. So push is used only as a lightweight alert to nudge users toward new messages — never as the reliable delivery path. Slack's pipeline enqueues a background job for every message, part of a queue system that runs over a billion jobs a day.

11

If you have time left#

Strong follow-ups to raise once the core design is done — each is a mini-design of its own:

PresenceHow WebSocket drops and TCP half-open states affect "online" accuracy.
Group chatFan-out of creation and delivery, role-based permissions, @-mentions.
EncryptionDatabase encryption on device, and end-to-end encryption between users.
Rich mediaAttachments with compression and thumbnail generation for previews.
Edit & deleteEdit history for transparency; delete for self vs everyone.
SearchIn-conversation and global full-text search across chats.
12

Recap#

Scope decides architecture1:1, text-only, relay-only backend, 30M DAU — each cut removes a subsystem.
Relay, not storageBackend stores-and-forwards, then forgets; the device is the source of truth.
Two channelsHTTP for client-initiated writes, WebSocket for server-pushed real-time events.
WebSocket, scopedChosen over SSE for low-latency duplex; limited to real-time to contain scaling cost.
Optimistic UIShow the message instantly; reconcile with the server's ID afterward.
Hybrid orderingAcknowledged messages sort by server ID; in-flight ones by client timestamp, at the bottom.
Requests vs messagesA separate MessageRequest models sends; on ack it's replaced by a real Message.
Resilient sendingExponential backoff, a retry ceiling, and a scheduler off the main thread.
SQLite, one fileRelational history in a single chats.db, partitioned and indexed as it grows.
Push is a nudgeFCM/APNs alert offline users; unreliable, so never the delivery path.
🎯
The single idea underneath it all

A chat client is a local-first database that reconciles with a store-and-forward relay over two channels. Almost every decision — optimistic display, hybrid ordering, request-vs-message modelling, backoff retries, SQLite as source of truth — falls out of trusting the device first and treating the network as something that will fail and recover.

13

Further reading#

All 33 source references, grouped by theme and checked live on 24 Aug 2026. Living API docs are flagged where they've moved on since the source was written.

Store-and-forward & the relay model#

[1]
WhatsApp Privacy Policy (EEA)Basis for the "hold until delivered, don't store permanently" relay stance.living legal doc

Real-time transport protocols#

[2]
Slack Socket ModeHow Slack runs WebSockets without a public endpoint.
[3]
Rebuilding Slack on the desktopWhy the client keeps a WebSocket per workspace for real-time updates.
[4]
How Uber built real-time chatA real WebSocket-to-GraphQL-subscriptions migration at 3M tickets/week.
[5]
Building scalable real-time chat (Uber)Uber's own write-up of the same system.
[6]
WhatsApp (Wikipedia)Context for WhatsApp's customized XMPP backbone.
[7]
XMPP for instant messagingWhat XMPP offers for messaging and presence.
[8]
Building Facebook MessengerWhy early Messenger chose MQTT for battery-friendly persistent connections.
[9]
Discord + WebRTC at scaleHow and why Discord uses WebRTC for voice/video.
[10]
IRC (MDN)The classic TCP channel-chat protocol, for contrast.

Hybrid HTTP + real-time APIs#

[11]
Slack Web APIThe HTTP side of Slack's write-over-HTTP, events-over-socket split.
[12]
Slack Socket Mode APIThe real-time counterpart to the Web API.
[13]
Discord GatewayA production WebSocket protocol: heartbeats, resume, intents.living API doc

Time, IDs & ordering#

[14]
Simple Precision Time Protocol (Meta)Clock sync as an option for cross-device ordering.
[15]
Snowflake IDTime-sortable distributed IDs — the shape a good messageId takes.now titled X/Twitter
[16]
Discord Message objectA real message schema with snowflake IDs and positions.living API doc
[17]
WhatsApp Cloud API — MessagesWhatsApp's message id in practice.living API doc
[18]
Messenger Platform — message eventsMessenger's mid field and delivery webhooks.living API doc
[19]
Slack message eventSlack's ts timestamp doubling as an ordering key.

Offline sync in the wild#

[20]
Messaging Sync at AirbnbDelta-sync design that halved inbox latency on slow networks.
[21]
Project LightSpeed (Messenger)MSYS: SQLite-centric local sync behind one prioritized queue.

On-device database libraries#

[22]
SQLite.swiftTyped SQLite for iOS.
[23]
FMDBObjective-C SQLite wrapper.
[24]
Jetpack RoomGoogle's recommended SQLite layer for Android.
[25]
SQLDelightCompile-time-checked SQL, multiplatform.

Push at scale#

[26]
Scaling Slack's Job QueueBillions of jobs a day, including push fan-out.

Concepts for the follow-ups#

[27]
Presence informationThe model behind online/away/typing state.
[28]
TCP half-openWhy a dropped socket can leave "online" stale.
[29]
Role-based access controlPermission model for group-chat management.
[30]
WhatsApp end-to-end encryptionThe E2E model for the security follow-up.living help doc
[31]
Full-text searchFoundation for in-app message search.
[32]
WhatsApp: full device storageReal-world eviction UX when storage runs low.living help doc
[33]
Slack privacy / retentionTiered message-retention policy as an eviction reference.living legal doc
Rewritten in original wording from ByteByteGo's Chat app chapter (Mobile System Design Interview). All diagrams are original SVGs, not traced from the source.
[+] marks context added beyond the source. Reference links were verified live on 24 August 2026; a few living API and help pages have been updated since the source was written and are flagged.