iosNudge

Design a Chat App

HardSystem designNetworkingConcurrencyPersistenceApp lifecycle 60 min read

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

  1. User taps Send. Save the message in the DB with status sending and a new clientID.
  2. The UI shows it at once (it reads from the DB).
  3. The outbox sends it over the WebSocket.
  4. The server saves it, gives it a seq number, and replies with an ack.
  5. The DB row becomes sent. Later events move it to delivered and read.

Receive flow

  1. A socket event brings a new message. Upsert it in the DB by clientID.
  2. The UI updates. The app sends a "delivered" receipt.
  3. 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:

  • clientID exists before the server knows the message. It lets us retry safely: the server ignores a second message with the same clientID.
  • seq is 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 push

Socket 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:

  1. Messages with a seq, in seq order.
  2. Then my pending messages (seq == nil), in createdAt order, 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)--> sending

Events 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 sent only when sendAck arrives.
  • 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 failed and show a "Tap to retry" icon.
  • Going to the background? Call UIApplication.shared.beginBackgroundTask to 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

DecisionOption AOption BUsual pick
Real-time channelWebSocket: two-way, low latencyLong polling: simple, more batteryWebSocket in foreground
BackgroundKeep socket open (not allowed for long)Close socket, use pushClose and use push
Message idServer id onlyClient clientID + server seqBoth: dedupe + order
OrderClient timestampServer seq per conversationServer seq
Group receiptsPer message, per memberreadUpTo per memberreadUpTo
OutboxIn memoryIn the databaseDatabase (survives a kill)
UI data sourceNetwork responsesLocal DB, observedLocal 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 seq for 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.