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.
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.
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.
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.
| Functional | Non-functional | Out of scope |
|---|---|---|
| Recent conversations, sorted by latest-message time | Scale: 30M daily active users, all network conditions | Auth & registration (assume logged in, contacts pre-loaded) |
| Real-time send & receive of text | Data integrity: stays consistent offline, queues sends for reconnect | Group chats & media sharing |
| Presence, typing, delivery status, read receipts | Durability: client data is the source of truth, so it must not be lost | On-device message encryption |
| Offline history, paginated | ||
| Push notifications for new messages |
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.
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.
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.
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.
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-IDto 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.
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.
| Option | Strengths | Weaknesses |
|---|---|---|
| Short polling | Dead simple; works on any HTTP stack, everywhere | Excessive requests; high bandwidth when there's no news |
| Long polling | Near real-time with fewer requests; fine for non-critical updates | Fiddly timeouts; resource-heavy on servers; scales poorly |
| SSE | Single open connection; built-in reconnect; good over HTTP/2 | Server→client only; needs a second channel to send; heavy at many connections |
| WebSocket | True bidirectional, low-latency, low per-message overhead | More infra (LB support); persistent connections are hard to scale |
Table 1 — Trade-offs for a server-initiated transport.
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.
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.
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:
| Protocol | Shape | Seen in |
|---|---|---|
| WebSocket | Full-duplex over one TCP connection | Slack real-time; formerly Uber |
| XMPP | XML-based messaging + presence | WhatsApp (customized), Kik |
| MQTT | Lightweight pub/sub over TCP | Early Facebook Messenger |
| WebRTC | P2P audio/video/data, UDP-first | Discord voice/video |
| IRC | Channel-based chat over TCP | Mozilla community |
HTTP + REST for client-initiated operations · WebSocket for server-pushed real-time events · WebSocket usage deliberately scoped to keep scaling manageable.
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 → SyncMessagesResponseThe 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:
| Model | Key fields |
|---|---|
| Message | messageId, fromUserId, toUserId, content,
status, createdAt |
| MessageStatus | PENDING (server hasn't got it) · SYNCED (server received) ·
DELIVERED (reached recipient) · READ |
| ConversationPreview | contact, lastMessageSummary, lastMessageTimestamp,
unreadCount — the Chat List row |
| User | id, name, avatarUrl, lastConnectedAt |
| Conversation | contact, contactInfo?, unreadCount, messages[] —
loaded on opening a chat |
| ContactInfo | onlineStatus, 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.
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.
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.
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.
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.
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 by | Upside | Downside |
|---|---|---|
| messageId (server) | Consistent across clients; backend is source of truth | Doesn't exist until the server responds; breaks offline sends |
| createdAt (client) | Instant display; works offline | Device-clock skew; client-manipulable |
| createdAt + clock sync | Timestamp benefits, fewer ordering bugs (e.g. Meta's SPTP) | Clock drift persists; complex to do accurately |
Table 2 — Ordering trade-offs on the client.
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.
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.
| MessageRequestStatus | Meaning |
|---|---|
DRAFT | Created, not yet sent by the client |
PENDING | Queued and ready to send |
SENT | Handed to the backend, no delivery ack yet |
FAILED | Server reported failure, or the request timed out |
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#
- The Messages Remote DataSource receives a success response.
- It passes the result to the Messages Repository.
- The repository does two DB writes: remove the temporary request, and insert a new
Messagewith the confirmed ID. - 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_readWebSocket 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.
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.
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)
);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.
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.
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.
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.
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.
If you have time left#
Strong follow-ups to raise once the core design is done — each is a mini-design of its own:
Recap#
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.
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.