Android System Design Interview Questions
Design WhatsApp.
Tier: CommonDifficulty: Hard
A chat app is a good test of whether you reach for WebSocket by default or actually reason about it, messaging genuinely needs a persistent connection, but the harder design problems are message ordering, delivery guarantees, and offline queuing, not the transport choice itself.
What I'd clarify first
- One-to-one only, or group chat too, since group chat changes fan-out and read-receipt logic meaningfully.
- Does this need end-to-end encryption in scope, or is transport-level security, plain TLS, enough for the purposes of this design.
- One device per user, or several. Multi device means a key and a sequence per device rather than per user, and it breaks the single last-sequence map the sync protocol below is built on.
- What delivery guarantees matter, is "at most once" acceptable or does a message need to survive the client crashing right after sending it.
Core components
- A persistent connection per online client, WebSocket, held while the app is on screen. Android closes it once the app is backgrounded, which is what the push path below is for. This is the one part of the system where request-response REST genuinely isn't the right shape, since either side can originate a message at any moment.
- A local message queue and store, Room-backed, every outgoing message is written locally first, in a "pending" state, before any network send is attempted, and every incoming message is written locally the moment it's received, this is what makes chat history durable and instantly available on app launch, no network wait to see your own conversation.
- A message delivery pipeline on the backend, when the recipient is online, deliver over their open socket immediately, when they're offline, queue the message server-side and fall back to a push notification through FCM to wake the app, with the message itself delivered over the socket once the app reconnects.
- A sync protocol for reconnection, each device tracks the last message it successfully received, per conversation, and on reconnect asks the server for anything newer than that, rather than the server trying to guess what a reconnecting client already has.
Message lifecycle and delivery states
A message moves through four states, and each one is a small explicit event travelling back to the sender.
- Sending, written locally the moment the user taps send, before any network call.
- Sent, the server acknowledged receipt.
- Delivered, the recipient's device acknowledged it arrived.
- Read, the recipient opened that conversation.
The UI's single and double checkmarks are just a rendering of that state, not something computed locally.
The code
The message state machine is the part worth writing down. Acknowledgements arrive out of order on a flaky connection, so state only ever moves forward, and a late "sent" ack can never pull a message that is already read back to one checkmark. Failed sits below sending rather than above read, which is what lets a message that failed locally and arrived anyway recover when a real acknowledgement lands.
The other two are the deduplication rule, by client generated id on the receiving side, and the resume path. On reconnect the client does two things in one breath. It opens the socket carrying the last sequence it actually stored per conversation, so the server sends exactly what was missed, and then it drains the outbox, oldest first, so the messages typed offline finally go. Push is modelled as a wake up rather than a second delivery path, so there is one ordering story instead of two. Encryption, group fan out and the media pipeline are out of this tree.
Java
com.androidinterview.chat.model.Message.java
package com.androidinterview.chat.model;
// clientId is generated on the sending device before any network call. It is
// what makes a retry safe, because the receiver drops a second copy carrying
// an id it has already stored, and it is what lets the sender match an
// acknowledgement to the row it wrote optimistically.
//
// serverSeq is assigned by the server and is what conversation order is read
// from. Two phones with two clocks cannot agree on an order, one server can.
public record Message(
String clientId,
String conversationId,
String senderId,
String text,
long serverSeq,
MessageState state) {
public Message withState(MessageState next) {
return state.advancesTo(next) ? new Message(clientId, conversationId, senderId, text, serverSeq, next) : this;
}
public Message sequenced(long seq) {
return new Message(clientId, conversationId, senderId, text, seq, state);
}
}
com.androidinterview.chat.model.MessageState.java
package com.androidinterview.chat.model;
// The checkmarks, as a type. Every one of these is an event travelling back to
// the sender, not something the client computes for itself.
public enum MessageState {
SENDING(0), SENT(1), DELIVERED(2), READ(3),
// Rank below sending on purpose. A send that failed locally and turns out
// to have arrived anyway recovers the moment a real acknowledgement lands,
// rather than being stuck at failed forever.
FAILED(-1);
private final int rank;
MessageState(int rank) {
this.rank = rank;
}
// State only moves forward. Acknowledgements arrive out of order on a
// flaky connection all the time, and without this rule a late sent ack
// pulls a message that is already read back down to one checkmark in front
// of the user.
public boolean advancesTo(MessageState next) {
if (next == FAILED) return this == SENDING;
return next.rank > this.rank;
}
}
com.androidinterview.chat.sync.ConversationSync.java
package com.androidinterview.chat.sync;
import com.androidinterview.chat.model.Message;
import com.androidinterview.chat.model.MessageState;
import com.androidinterview.chat.transport.ChatEvent;
import com.androidinterview.chat.transport.ChatTransport;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// Everything that happens around a socket that drops. Room sits behind the
// store, so the conversation is on screen before any connection exists.
public final class ConversationSync implements ChatTransport.Listener {
public interface MessageStore {
void put(Message message);
Message find(String clientId);
boolean contains(String clientId);
// Rows still in sending, oldest first. This is the outbox. Failed rows
// are not in it, they wait for the user's retry.
List<Message> pending();
Map<String, Long> lastSeqByConversation();
}
private final MessageStore store;
private final ChatTransport transport;
private final Map<String, Long> lastSeq = new HashMap<>();
public ConversationSync(MessageStore store, ChatTransport transport) {
this.store = store;
this.transport = transport;
this.lastSeq.putAll(store.lastSeqByConversation());
}
// Write first, send second. The row exists in the pending state whether or
// not the socket is up, which is what lets a user type four messages in a
// lift and watch them go out when the doors open.
public void send(Message composed) {
store.put(composed);
if (transport.connected()) transport.send(composed);
}
// Connectivity returned, or a push woke the app. Two steps in one breath.
// The socket opens with the last sequence this device actually stored, per
// conversation, so the server sends exactly what was missed rather than
// guessing. Then the outbox drains, oldest first. Without that second step
// the rows written in the lift sit in pending forever.
public void resume() {
transport.connect(new HashMap<>(lastSeq));
for (Message message : store.pending()) transport.send(message);
}
@Override
public void onEvent(ChatEvent event) {
if (event instanceof ChatEvent.Incoming incoming) {
Message message = incoming.message();
// At least once delivery means the same message can arrive twice,
// so the receiver deduplicates by client id. Far cheaper than
// building exactly once delivery, and it is what real chat systems
// do.
if (store.contains(message.clientId())) return;
store.put(message);
// Safe because a single socket delivers in order. If sequences
// could arrive out of order this would have to track the highest
// contiguous sequence, not the highest.
lastSeq.merge(message.conversationId(), message.serverSeq(), Math::max);
} else if (event instanceof ChatEvent.Ack ack) {
Message stored = store.find(ack.clientId());
if (stored != null) store.put(stored.sequenced(ack.serverSeq()).withState(ack.state()));
} else if (event instanceof ChatEvent.Online) {
resume();
}
}
// The transport gave up on a send. Only a row still in sending can fail,
// a message the server already acknowledged stays where it is.
public void markFailed(String clientId) {
Message stored = store.find(clientId);
if (stored != null) store.put(stored.withState(MessageState.FAILED));
}
}
com.androidinterview.chat.transport.ChatEvent.java
package com.androidinterview.chat.transport;
import com.androidinterview.chat.model.Message;
import com.androidinterview.chat.model.MessageState;
// What arrives over the socket, as one closed set. The router demultiplexes by
// conversation id from a single connection, because one socket carrying every
// conversation is the only version that scales, and a sealed type is what
// keeps a new event from being quietly ignored.
public sealed interface ChatEvent {
record Incoming(Message message) implements ChatEvent {
}
record Ack(String clientId, MessageState state, long serverSeq) implements ChatEvent {
}
// Connectivity returned, or a push woke the app. The socket is not open
// yet, the sync opens it with the resume request.
record Online() implements ChatEvent {
}
}
com.androidinterview.chat.transport.ChatTransport.java
package com.androidinterview.chat.transport;
import com.androidinterview.chat.model.Message;
import java.util.Map;
// The socket, behind an interface. OkHttp's WebSocket sits here in a real app,
// and a push from FCM is not a second delivery path for message content, it is
// only a wake up. The payload the client trusts always arrives over the socket,
// so there is one ordering and one deduplication story instead of two.
public interface ChatTransport {
boolean connected();
// Opening the socket carries the resume request, so a reconnect and a
// catch up are one round trip rather than two.
void connect(Map<String, Long> lastSeqByConversation);
void send(Message message);
interface Listener {
void onEvent(ChatEvent event);
}
}
Kotlin
com.androidinterview.chat.model.Message.kt
package com.androidinterview.chat.model
// The checkmarks, as a type. Every one of these is an event travelling back to
// the sender, not something the client computes for itself. FAILED ranks below
// SENDING on purpose, so a send that failed locally and turns out to have
// arrived anyway recovers when a real acknowledgement lands.
enum class MessageState(val rank: Int) {
FAILED(-1), SENDING(0), SENT(1), DELIVERED(2), READ(3);
// State only moves forward. Acknowledgements arrive out of order on a
// flaky connection all the time, and without this rule a late sent ack
// pulls a message that is already read back down to one checkmark in front
// of the user.
infix fun advancesTo(next: MessageState): Boolean =
if (next == FAILED) this == SENDING else next.rank > rank
}
// clientId is generated on the sending device before any network call. It is
// what makes a retry safe, because the receiver drops a second copy carrying
// an id it already stored, and what lets the sender match an acknowledgement
// to the row it wrote optimistically.
//
// serverSeq is assigned by the server and is what conversation order is read
// from. Two phones with two clocks cannot agree on an order, one server can.
data class Message(
val clientId: String,
val conversationId: String,
val senderId: String,
val text: String,
val serverSeq: Long = 0,
val state: MessageState = MessageState.SENDING,
) {
fun advance(next: MessageState): Message =
if (state advancesTo next) copy(state = next) else this
}
com.androidinterview.chat.sync.ConversationSync.kt
package com.androidinterview.chat.sync
import com.androidinterview.chat.model.Message
import com.androidinterview.chat.model.MessageState
import com.androidinterview.chat.transport.ChatEvent
import com.androidinterview.chat.transport.ChatTransport
// Room sits behind the store, so a conversation is on screen before any
// connection exists.
interface MessageStore {
fun put(message: Message)
fun find(clientId: String): Message?
operator fun contains(clientId: String): Boolean
// Rows still in sending, oldest first. This is the outbox. Failed rows are
// not in it, they wait for the user's retry.
fun pending(): List<Message>
fun lastSeqByConversation(): Map<String, Long>
}
class ConversationSync(
private val store: MessageStore,
private val transport: ChatTransport,
) {
private val lastSeq = store.lastSeqByConversation().toMutableMap()
// Write first, send second. The row exists in the pending state whether or
// not the socket is up, which is what lets a user type four messages in a
// lift and watch them go out when the doors open.
suspend fun send(composed: Message) {
store.put(composed)
if (transport.connected) transport.send(composed)
}
// Connectivity returned, or a push woke the app. Two steps in one breath.
// The socket opens with the last sequence this device actually stored, per
// conversation, so the server sends exactly what was missed rather than
// guessing. Then the outbox drains, oldest first. Without that second step
// the rows written in the lift sit in pending forever.
suspend fun resume() {
transport.connect(lastSeq.toMap())
store.pending().forEach { transport.send(it) }
}
suspend fun onEvent(event: ChatEvent) {
when (event) {
// At least once delivery means the same message can arrive twice,
// so the receiver deduplicates by client id. Far cheaper than
// building exactly once delivery, and it is what real chat systems
// do.
is ChatEvent.Incoming -> if (event.message.clientId !in store) {
store.put(event.message)
// Safe because a single socket delivers in order. If sequences
// could arrive out of order this would have to track the
// highest contiguous sequence, not the highest.
lastSeq.merge(event.message.conversationId, event.message.serverSeq, ::maxOf)
}
is ChatEvent.Ack -> store.find(event.clientId)
?.copy(serverSeq = event.serverSeq)
?.advance(event.state)
?.let(store::put)
ChatEvent.Online -> resume()
}
}
// The transport gave up on a send. Only a row still in sending can fail, a
// message the server already acknowledged stays where it is.
fun markFailed(clientId: String) {
store.find(clientId)?.advance(MessageState.FAILED)?.let(store::put)
}
}
com.androidinterview.chat.transport.ChatTransport.kt
package com.androidinterview.chat.transport
import com.androidinterview.chat.model.Message
import com.androidinterview.chat.model.MessageState
// What arrives over the socket, as one closed set. The router demultiplexes by
// conversation id from a single connection, because one socket carrying every
// conversation is the only version that scales, and an exhaustive when is what
// keeps a new event type from being quietly ignored.
sealed interface ChatEvent {
data class Incoming(val message: Message) : ChatEvent
data class Ack(val clientId: String, val state: MessageState, val serverSeq: Long) : ChatEvent
// Connectivity returned, or a push woke the app. The socket is not open
// yet, the sync opens it with the resume request.
data object Online : ChatEvent
}
// OkHttp's WebSocket sits behind this. A push from FCM is not a second delivery
// path for content, it is only a wake up, so there is one ordering and one
// deduplication story rather than two.
interface ChatTransport {
val connected: Boolean
// Opening the socket carries the resume request, so a reconnect and a
// catch up are one round trip rather than two.
suspend fun connect(lastSeqByConversation: Map<String, Long>)
suspend fun send(message: Message)
}
Tradeoffs I'd call out
- At-least-once delivery vs exactly-once. Guaranteeing a message is never lost, retry until acknowledged, means a client might occasionally receive the same message twice if an acknowledgment itself gets lost in transit. Deduplicating by a client-generated message ID on the receiving end is simpler and cheaper than building true exactly-once delivery, and is what most chat systems actually do.
- Server-relayed messages vs end-to-end encryption. Plain TLS to the server is simpler to build and lets the server do things like generate link previews or scan for spam. End-to-end encryption, in practice the Signal protocol's double ratchet, a fresh key per message so a stolen key unlocks one message and not the history, means the server can never read content even if it wanted to. That is a real product and trust commitment, and those server-side conveniences either disappear or have to be rebuilt without the server ever seeing plaintext.
- A single global socket vs one connection per conversation. One socket carrying all of a user's conversations is far more efficient, one connection to maintain instead of dozens, and it's what real systems do. It does mean the client-side message router has to demultiplex incoming messages by conversation ID itself, rather than each conversation having an isolated channel.
What breaks at scale, offline, and on a poor connection
At scale, holding millions of concurrent open sockets is a real infrastructure problem on its own, this is usually solved with a dedicated connection-handling tier, separate from the services that store and route messages, so that tier can scale independently just to hold connections open. Offline, the local-first write, message saved locally before it's sent, is what makes the whole feature usable at all, a user can compose and "send" several messages with no connection and see them sitting in a clear "pending" state, delivered automatically the moment connectivity returns. On a poor connection, the socket needs automatic reconnection with backoff, and the sync-since-last-message-id protocol on reconnect is what prevents either duplicate messages or gaps after a connection drop mid-conversation.
Watch