Design a chat application's mobile client — message delivery guarantees, WebSockets, offline queueing
Reason through sent/delivered/read semantics, reconnect strategy, and message ordering the way a real WhatsApp/Slack mobile client has to.
LEARN BY DOINGPut your intuition to work3 steps
Design a chat application's mobile client — message delivery guarantees, WebSockets, offline queueing
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Clarifying the requirements
Chat is deceptively simple to demo and brutally hard to get right in production, so I'd start by nailing down exactly what guarantee we're promising. I'd ask: is this one-to-one and group messaging, or also presence/typing indicators? Do we need read receipts? What's the tolerance for duplicate messages versus lost messages — because those two failure modes trade off against each other, and "never lose a message" pushes you toward at-least-once delivery, which means the client must handle duplicates. I'd also ask about ordering guarantees within a conversation (do messages need to render in a strict, consistent order across all participants' devices, even under network partitions?) and about message size/attachment support, since large attachments are usually handled by a separate upload pipeline rather than riding through the same channel as text. For this answer, I'll design for the standard target: at-least-once delivery, client-side dedup, per-conversation ordering, and full offline composition support — which is the bar WhatsApp, Signal, and Slack's mobile clients all clear.
#High-level architecture
The mobile client's job splits cleanly into three concerns that are easy to conflate but shouldn't be: the transport (how bytes move between client and server right now), the local message store (the source of truth the UI actually renders from — same principle as the offline-notes design: never let the UI read directly off the socket), and the outbox (a durable queue of messages the user has composed that haven't been confirmed sent yet). A message the user types is written to the local store and the outbox in one local transaction before any network attempt — so the message appears in the UI instantly with a "sending" state, survives an app kill, and gets retried automatically on the next connectivity window, regardless of whether the WebSocket was even connected at the moment of typing.
#Delivery guarantees and idempotency
I'd design for at-least-once delivery on the wire because it's the only option that survives real networks — a client can send a message, the server can process and persist it, and the ACK can be lost before it reaches the client, in which case the client correctly retries, and now the server has received the same message twice. The fix isn't to chase exactly-once at the transport level (that's a much harder distributed-systems problem than the payoff justifies here); it's to make the server-side write idempotent using a client-generated message ID (a UUID created at compose time, before any network involvement). The server's insert is an upsert keyed on that ID, so a duplicate send is a no-op that just re-returns the original ACK. This single design choice — client-owned idempotency keys — is what makes retries safe to fire aggressively without a "did I already send this?" negotiation.
#Message ordering
Ordering within a conversation matters more than global ordering across conversations, and it's tempting to rely on server receive-time, but that breaks the moment two devices are composing near-simultaneously with variable network latency — server arrival order isn't the same as compose order, and users notice when a reply appears above the message it's replying to. I'd have the server assign a monotonically increasing sequence number per conversation at the moment of durable write (not at receive time), and have every client render strictly by that sequence number, never by local timestamp or arrival order. Locally composed, not-yet-acked messages sort after the last known sequence number using a client-side placeholder ordering (e.g., insertion order within the "pending" bucket), and get re-sorted into their real position once the ACK arrives with the authoritative sequence number.
#The WebSocket connection lifecycle
A single persistent WebSocket per app session is the right transport for the live path — it gives low-latency delivery and lets the server push receipts and new messages without polling — but the client has to treat the connection as fundamentally unreliable and temporary: backgrounding, cellular-to-WiFi handoff, and OS-level connection reaping all kill it constantly on mobile in ways they don't on a desktop client. The reconnect strategy needs exponential backoff with jitter (to avoid a thundering herd of reconnects after a server restart), and — critically — a resume/replay protocol on reconnect: the client tells the server the last sequence number it has per active conversation, and the server replays anything missed rather than the client re-fetching full history. On mobile specifically, I'd also register for push notifications (APNs/FCM) as a fallback delivery path for when the app is backgrounded or killed and the socket doesn't exist at all — the push payload can carry enough to show a notification and trigger a background fetch/reconnect-and-sync when the app is foregrounded again.
#Offline queueing and the outbox in detail
The outbox is a persisted table, not an in-memory queue — messages composed while fully offline (no socket, no network at all) need to survive an app kill and a device reboot and still send once connectivity returns. Each outbox entry carries the client message ID, target conversation, payload, creation timestamp, and retry state (attempt count, next-retry-at). A connectivity observer (network reachability callback) or just periodic wake via a background task drains the outbox in FIFO order per conversation when a connection becomes available, respecting the same idempotency-key contract as the live send path — so "offline send" and "live send" are the same code path with a different trigger, not two parallel implementations that can drift.
#Read receipts, presence, and the cost of "chattiness"
Read receipts and typing indicators are lower-priority, higher-frequency signals than messages themselves, and I'd explicitly deprioritize and possibly batch/coalesce them (e.g., a typing indicator only needs to fire once every few seconds, not on every keystroke; a read receipt for ten messages opened at once can be a single "read up to sequence N" event rather than ten individual receipts) so they don't compete with actual message delivery for bandwidth and battery on a poor connection. This is a good place to acknowledge the tradeoff explicitly in an interview: perfect real-time presence is expensive on mobile radios, and a slightly debounced typing indicator is an entirely reasonable, deliberate degradation.
#Wrapping up
If pushed on the biggest risk in this design, I'd say it's the reconnect/resume protocol — it's the part most likely to have subtle bugs (off-by-one sequence numbers, a missed message during a race between socket-close and outbox-drain) and the part that's hardest to test because it requires simulating real network flakiness rather than a clean unit test. I'd invest disproportionately in integration tests that kill the socket mid-conversation and assert the client converges to the same state as the server, because that's the scenario real users hit constantly and the one a demo never exercises.