Android System Design Interview Questions
Design WhatsApp.
Tier: CommonDifficulty: Hard
Design a messaging app where users can send messages, read conversation history and see delivery progress. A message composed without a connection should remain on the device and be sent when connectivity returns.
The problem
Begin with one to one text conversations and one device per user. Cover local message storage, sending, receiving, reconnecting and the difference between pending, sent, delivered and read. Agree separately on groups, attachments, multiple devices and end to end encryption.
For example, Ana sends Hi while offline and sees it marked pending. After reconnecting, the app sends it and updates its status. If the server saves the message but its reply is lost, a retry must not create a second Hi in Ben's conversation. Ben opening the message can later produce a read receipt.
The exercise asks for the client architecture and server contracts that preserve messages and ordering through interruptions. It is an interview design, not a description of WhatsApp's internal implementation.
Starting the design
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);
}
}
package com.androidinterview.chat.model;
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;
}
}
package com.androidinterview.chat.model;
public enum MessageState {
SENDING(0), SENT(1), DELIVERED(2), READ(3),
FAILED(-1);
private final int rank;
MessageState(int rank) {
this.rank = rank;
}
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));
}
}
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;
public final class ConversationSync implements ChatTransport.Listener {
public interface MessageStore {
void put(Message message);
Message find(String clientId);
boolean contains(String clientId);
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());
}
public void send(Message composed) {
store.put(composed);
if (transport.connected()) transport.send(composed);
}
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();
if (store.contains(message.clientId())) return;
store.put(message);
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();
}
}
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 {
}
}
package com.androidinterview.chat.transport;
import com.androidinterview.chat.model.Message;
import com.androidinterview.chat.model.MessageState;
public sealed interface ChatEvent {
record Incoming(Message message) implements ChatEvent {
}
record Ack(String clientId, MessageState state, long serverSeq) implements ChatEvent {
}
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);
}
}
package com.androidinterview.chat.transport;
import com.androidinterview.chat.model.Message;
import java.util.Map;
public interface ChatTransport {
boolean connected();
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
}
package com.androidinterview.chat.model
enum class MessageState(val rank: Int) {
FAILED(-1), SENDING(0), SENT(1), DELIVERED(2), READ(3);
infix fun advancesTo(next: MessageState): Boolean =
if (next == FAILED) this == SENDING else next.rank > rank
}
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)
}
}
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
interface MessageStore {
fun put(message: Message)
fun find(clientId: String): Message?
operator fun contains(clientId: String): Boolean
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()
suspend fun send(composed: Message) {
store.put(composed)
if (transport.connected) transport.send(composed)
}
suspend fun resume() {
transport.connect(lastSeq.toMap())
store.pending().forEach { transport.send(it) }
}
suspend fun onEvent(event: ChatEvent) {
when (event) {
is ChatEvent.Incoming -> if (event.message.clientId !in store) {
store.put(event.message)
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()
}
}
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)
}
package com.androidinterview.chat.transport
import com.androidinterview.chat.model.Message
import com.androidinterview.chat.model.MessageState
sealed interface ChatEvent {
data class Incoming(val message: Message) : ChatEvent
data class Ack(val clientId: String, val state: MessageState, val serverSeq: Long) : ChatEvent
data object Online : ChatEvent
}
interface ChatTransport {
val connected: Boolean
suspend fun connect(lastSeqByConversation: Map<String, Long>)
suspend fun send(message: Message)
}
How I'd build this on Android
I'd use MVVM with ChatRepository between the screen, Room, the history API and the socket. Each message has a client ID, conversation ID, sender, body, status and a server sequence number once accepted. Sending saves the message and its upload operation in one transaction. When the server acknowledges it, the repository updates that same row, so the UI does not create a second bubble.
@Entity(tableName = "messages", indices = [Index(value = ["conversationId", "sequence"])])
data class MessageRow(
@PrimaryKey val clientId: String,
val conversationId: String,
val text: String,
val sequence: Long?,
val state: String, // Pending, Sent, Delivered, Read, Failed
)
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :id ORDER BY sequence DESC, clientId")
fun history(id: String): PagingSource<Int, MessageRow>
@Upsert suspend fun upsert(message: MessageRow)
}
interface ChatRepository {
fun messages(conversationId: String): Flow<PagingData<MessageRow>>
suspend fun enqueue(conversationId: String, clientId: String, text: String)
}
class ChatViewModel(
state: SavedStateHandle,
private val repository: ChatRepository,
) : ViewModel() {
private val conversationId: String = checkNotNull(state["conversationId"])
val messages = repository.messages(conversationId).cachedIn(viewModelScope)
// Called by the send callback, never by composition or reconnect.
suspend fun send(text: String) {
if (text.isNotBlank()) repository.enqueue(
conversationId, UUID.randomUUID().toString(), text.trim(),
)
}
}
Here, send is a suspend function. The ViewModel calls it in viewModelScope, prevents accidental repeated taps and keeps the draft until Room saves it successfully. A save failure stays visible. Pending messages need an explicit local order, since random UUIDs do not sort by send time. Accepted messages use the server sequence. Save outgoing messages before sending them, and save incoming messages before advancing the cursor or acknowledging receipt.
A Room PagingSource provides the list, and RemoteMediator loads older history. Socket messages also write to Room, so there is one path from saved data to the screen. I'd share one socket connection per signed in account.
Compose uses collectAsLazyPagingItems(), the client ID as the key, and different content types for incoming messages, outgoing messages, dates and attachments. Pending messages stay visible as history loads. If the user is reading older messages, new messages show a banner. Auto scroll happens only when they are already at the bottom.
The socket handles foreground updates. In the background, FCM tells the app to fetch missing messages. It is not the message database. WorkManager retries queued sends. Repositories and keys belong to the current account, and logs exclude message text. End to end encryption also needs device and key management, which TLS alone does not provide.
I'd inject the DAO, API and session connection manager into the repository, then the repository into the ViewModel. The Notes example shows the factory and Hilt setup.
I'd check these cases.
- Lost acknowledgements.
- Duplicate messages.
- Sequence gaps.
- A crash after saving.
- Overlap between history and live messages.
- Logout.
I'd measure acknowledgement time, the oldest pending message and unresolved gaps. See Paging with network and database.
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 during a conversation.
Watch