Design a Chat App
Requirements
You design the iOS client of a chat app, like a simple WhatsApp or iMessage. The hard parts are: no lost messages, no duplicates, correct order, and offline sending.
Functional
- 1:1 chats and group chats (up to about 200 members).
- Send and receive text messages. Images are a nice extra.
- Delivery states: sending, sent, delivered, read.
- Send while offline. Messages go out when the network comes back.
- Load older messages when the user scrolls up.
- Push notifications when the app is not open.
- Typing indicator (nice to have).
Non-functional
- Low latency: a message shows on the other phone in under one second when both are online.
- Never lose a message the user sent. Never show a message twice.
- The same order on every device.
- Battery friendly: no open socket while the app is in the background.
- Secure: TLS everywhere. Tokens in the Keychain.
Out of scope
- Voice and video calls.
- End-to-end encryption details (mention it as a follow-up).
- Server scaling and sharding.
Questions to ask the interviewer
- How big are groups? Do we need read receipts in groups?
- One device per user, or many (phone + iPad + Mac)?
- Media messages? Edit and delete?
- Is end-to-end encryption required?
- How long do we keep history on the phone?
High-level architecture
+------------+ +-----------------+ +----------------------+
| ChatView |<--->| ChatViewModel |<--->| MessageStore |
| (list + | | @Observable | | SQLite / SwiftData |
| input) | +-----------------+ | single source of |
+------------+ | truth for the UI |
+-----^-----------^----+
| |
+-------------+ +------------+
| |
+--------+--------+ +--------+--------+
| SyncEngine |<------------------>| Outbox |
| socket events, | | pending sends, |
| gap catch-up | | retry |
+--------+--------+ +-----------------+
|
WebSocket + | REST (history, catch-up)
v
+-----------------+ +-----------------+
| Chat server |--------->| APNs |
+-----------------+ offline +-----------------+Key rule: the UI only reads from the local database. The network writes into the database. The UI observes the database. This makes offline, ordering and dedupe much easier.
Send flow
- User taps Send. Save the message in the DB with status
sendingand a newclientID. - The UI shows it at once (it reads from the DB).
- The outbox sends it over the WebSocket.
- The server saves it, gives it a
seqnumber, and replies with an ack. - The DB row becomes
sent. Later events move it todeliveredandread.
Receive flow
- A socket event brings a new message. Upsert it in the DB by
clientID. - The UI updates. The app sends a "delivered" receipt.
- If the socket was down, call the REST catch-up endpoint first.
Data model
struct Message: Identifiable, Codable, Sendable, Equatable {
let clientID: UUID // made on the sender's phone, used for dedupe
var serverID: String? // set when the server acks
let conversationID: String
let senderID: String
let body: MessageBody
let createdAt: Date // sender's clock: only for display
var seq: Int64? // order from the server; nil while sending
var status: DeliveryStatus
var id: UUID { clientID }
}
enum MessageBody: Codable, Sendable, Equatable {
case text(String)
case image(url: URL, width: Int, height: Int)
}
enum DeliveryStatus: String, Codable, Sendable {
case failed, sending, sent, delivered, read
}
struct Conversation: Identifiable, Codable, Sendable {
let id: String
var title: String
var memberIDs: [String]
var lastMessagePreview: String?
var lastSeq: Int64 // highest seq we have stored
var unreadCount: Int
var readUpTo: [String: Int64] // userID -> last seq they read
}Two ids per message:
clientIDexists before the server knows the message. It lets us retry safely: the server ignores a second message with the sameclientID.seqis the order. Only the server gives it, so all devices agree.
API / interfaces
Server endpoints
wss://chat.example.com/v1/socket Authorization: Bearer <token>
GET /v1/conversations list with last message and unread count
GET /v1/conversations/{id}/messages?beforeSeq=900&limit=50 older history
GET /v1/conversations/{id}/messages?afterSeq=812 fill a gap
POST /v1/devices { "apnsToken": "..." } register for pushSocket events (JSON with a type field):
enum ClientEvent: Encodable, Sendable {
case send(clientID: UUID, conversationID: String, body: MessageBody)
case delivered(conversationID: String, upToSeq: Int64)
case read(conversationID: String, upToSeq: Int64)
case typing(conversationID: String)
}
enum ServerEvent: Decodable, Sendable {
case sendAck(clientID: UUID, serverID: String, seq: Int64)
case message(Message)
case receipt(conversationID: String, userID: String, status: DeliveryStatus, upToSeq: Int64)
case typing(conversationID: String, userID: String)
}Swift protocols
protocol ChatTransport: Sendable {
var events: AsyncStream<ServerEvent> { get }
func connect(token: String) async
func send(_ event: ClientEvent) async throws
func disconnect() async
}
protocol MessageStore: Sendable {
func messages(in conversationID: String, beforeSeq: Int64?, limit: Int) async throws -> [Message]
func upsert(_ messages: [Message]) async throws // insert or update by clientID
func setStatus(_ status: DeliveryStatus, clientID: UUID) async throws
func pendingOutbox() async throws -> [Message] // status == .sending, oldest first
}
protocol ChatAPI: Sendable {
func messages(in conversationID: String, afterSeq: Int64) async throws -> [Message]
}Deep dives
1. Real-time connection: WebSocket
Use URLSessionWebSocketTask. Open it when the app is in the foreground. Close it in the background.
actor WebSocketTransport: ChatTransport {
nonisolated let events: AsyncStream<ServerEvent>
private let continuation: AsyncStream<ServerEvent>.Continuation
private let url: URL
private var task: URLSessionWebSocketTask?
init(url: URL) {
self.url = url
let (stream, continuation) = AsyncStream.makeStream(of: ServerEvent.self)
self.events = stream
self.continuation = continuation
}
func connect(token: String) {
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.webSocketTask(with: request)
self.task = task
task.resume()
Task { await receiveLoop(task) }
}
private func receiveLoop(_ task: URLSessionWebSocketTask) async {
do {
while true {
let message = try await task.receive()
if case .data(let data) = message,
let event = try? JSONDecoder().decode(ServerEvent.self, from: data) {
continuation.yield(event)
}
}
} catch {
// The socket closed or failed. The SyncEngine will reconnect with backoff.
}
}
func send(_ event: ClientEvent) async throws {
guard let task else { throw URLError(.notConnectedToInternet) }
try await task.send(.data(JSONEncoder().encode(event)))
}
func disconnect() {
task?.cancel(with: .goingAway, reason: nil)
task = nil
}
}Keep it alive. Mobile networks drop idle connections. Send a ping every 25–30 seconds. If no pong comes back, treat the socket as dead and reconnect.
Reconnect with backoff and jitter. "Backoff" means waiting longer after each failure. "Jitter" means adding a random part, so a million phones do not reconnect in the same second.
func reconnectDelay(attempt: Int) -> Duration {
let base = min(30.0, pow(2.0, Double(attempt))) // 1, 2, 4, 8 ... max 30 s
return .seconds(base + Double.random(in: 0...(base * 0.3)))
}Also use NWPathMonitor. When the network comes back, reconnect right away instead of waiting.
After every reconnect, catch up. Events sent while the socket was down are lost.
Ask the server for messages after our last seq in each active conversation.
2. Message ordering
Phone clocks are wrong. Two people can send at the "same" time. So the order comes from the server:
each conversation has a counter, and each message gets the next seq.
Alice sends "Hi" -> server gives seq 41
Bob sends "Hello" -> server gives seq 42
Every device sorts by seq: 41, 42. Same order everywhere.How the list is sorted:
- Messages with a
seq, inseqorder. - Then my pending messages (
seq == nil), increatedAtorder, at the bottom.
Gap detection. We have seq 41. A socket event brings seq 44. We missed 42 and 43. Fetch them before we show 44.
actor SyncEngine {
private let api: ChatAPI
private let store: MessageStore
private var lastSeq: [String: Int64] = [:] // per conversation
init(api: ChatAPI, store: MessageStore) {
self.api = api
self.store = store
}
func handleIncoming(_ message: Message) async throws {
guard let seq = message.seq else { return }
let conversation = message.conversationID
let known = lastSeq[conversation] ?? 0
if seq <= known { return } // duplicate or old: ignore
if seq > known + 1 { // gap: we missed some
let missing = try await api.messages(in: conversation, afterSeq: known)
try await store.upsert(missing)
}
try await store.upsert([message])
// Re-read after await: another event may have changed it (actor reentrancy).
lastSeq[conversation] = max(lastSeq[conversation] ?? 0, seq)
}
}Because upsert uses clientID, getting the same message twice is harmless.
This is why the design is safe even when the socket and REST return the same message.
3. Delivery states
A message moves through a simple state machine:
sending --(server ack)--> sent --(their phone got it)--> delivered --(they opened chat)--> read
|
+--(gave up after retries)--> failed --(user taps Retry)--> sendingEvents can arrive late or twice. So a state only moves forward:
extension DeliveryStatus {
var rank: Int {
switch self {
case .failed: 0
case .sending: 1
case .sent: 2
case .delivered: 3
case .read: 4
}
}
func canMove(to new: DeliveryStatus) -> Bool {
if self == .failed && new == .sending { return true } // manual retry
return new.rank > rank
}
}A late "delivered" event can never turn a "read" message back to "delivered".
Receipts in groups. Do not store "read" for every message and every member. That is
200 members x 10,000 messages. Store one number per member: readUpTo[userID] = seq.
A message is read by a member if message.seq <= readUpTo[member]. "Read by 12 of 30" is easy to count.
4. Offline outbox
The outbox is the list of messages with status sending. It lives in the database, not in memory.
If the app is killed, the messages are still there on the next launch.
actor Outbox {
private let store: MessageStore
private let transport: ChatTransport
private let me: String
private var isFlushing = false
init(store: MessageStore, transport: ChatTransport, me: String) {
self.store = store
self.transport = transport
self.me = me
}
func send(text: String, in conversationID: String) async throws {
let message = Message(clientID: UUID(), serverID: nil, conversationID: conversationID,
senderID: me, body: .text(text), createdAt: .now,
seq: nil, status: .sending)
try await store.upsert([message]) // 1. save first: the UI shows it now
await flush() // 2. then try to send
}
/// Called after send, after every reconnect, and on app launch.
func flush() async {
guard !isFlushing else { return }
isFlushing = true
defer { isFlushing = false }
let pending = (try? await store.pendingOutbox()) ?? [] // oldest first
for message in pending {
do {
try await transport.send(.send(clientID: message.clientID,
conversationID: message.conversationID,
body: message.body))
} catch {
break // still offline: stop, keep the order, try again later
}
}
}
}Important details:
- "Written to the socket" is not "sent". The status becomes
sentonly whensendAckarrives. - No ack in 10 seconds? Send again with the same
clientID. The server drops the copy. - Keep the order. Send pending messages oldest first. Stop at the first failure.
- Give up after a while (for example 24 hours). Mark it
failedand show a "Tap to retry" icon. - Going to the background? Call
UIApplication.shared.beginBackgroundTaskto get a few seconds to flush the outbox before the app is suspended.
5. Push notifications
When the app is in the background, the socket is closed. The server sees the user is offline and sends a push through APNs.
- Use the conversation id as
thread-id, so iOS groups notifications per chat. - Use a Notification Service Extension (
mutable-content: 1). It can change the text before it shows, for example to decrypt it or to add the sender's photo. - Tapping the notification opens the right chat (a deep link with the conversation id).
- The badge number comes from the server, because only the server knows all unread chats.
- Do not trust push for data. Silent pushes can be delayed or dropped. When the app opens, always run the catch-up sync.
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
Task { try? await api.registerDevice(apnsToken: token) }
}History and memory. Load the last 50 messages when a chat opens. Load 50 more with beforeSeq
when the user scrolls to the top. Messages stay in the database. The view model only holds what is
on screen, plus some margin.
Trade-offs
| Decision | Option A | Option B | Usual pick |
|---|---|---|---|
| Real-time channel | WebSocket: two-way, low latency | Long polling: simple, more battery | WebSocket in foreground |
| Background | Keep socket open (not allowed for long) | Close socket, use push | Close and use push |
| Message id | Server id only | Client clientID + server seq | Both: dedupe + order |
| Order | Client timestamp | Server seq per conversation | Server seq |
| Group receipts | Per message, per member | readUpTo per member | readUpTo |
| Outbox | In memory | In the database | Database (survives a kill) |
| UI data source | Network responses | Local DB, observed | Local DB |
What interviewers look for
- The local database is the single source of truth. The UI never waits for the network.
- Client-generated ids for idempotent retries, server
seqfor order. - Gap detection and catch-up after reconnect.
- Delivery states that only move forward, and the difference between "written" and "acked".
- A persistent outbox that survives app kills and keeps the send order.
- Socket in foreground, push in background. You know silent push is not reliable.
- Reconnect with exponential backoff, jitter, and
NWPathMonitor. - Efficient group read receipts (
readUpTo). - Follow-ups: end-to-end encryption, multi-device sync, media upload with progress, message search, deleting messages for everyone.