Free lesson · Mobile System Design Interviews
Chat Application - API Design
Step 2: The Communication Layer
Here's where the chat app becomes genuinely different from the social feed. In the previous module, you learned about REST APIs: the client asks, the server responds. That works fine for fetching a feed - you pull down the latest posts, and you're done.
But in chat, the server needs to push data to the client. When Bob sends a message to Alice, Alice's phone needs to know about it immediately, without Alice's app asking "Is there a new message?" every second.
This requirement forces us to choose our communication protocols carefully. Let's walk through the decision.
The Protocol Decision: HTTP + WebSocket
Think of it like this:
HTTP is like sending a letter. Alice writes a message, puts it in an envelope, and mails it to Bob. Bob receives it later. Great for one-way communication. But if Bob wants to reply, he has to go through the same process. Back and forth, back and forth.
WebSocket is like an open phone line. Alice and Bob establish a connection, and either one can speak anytime without waiting for the other to ask. It's bidirectional and continuous.
For chat, we use both:
- HTTP for sending messages (the "postal service")
- WebSocket for receiving messages and real-time events (the "phone line")
Why not pure WebSocket? Because HTTP is stateless and simpler for writes. Why not pure HTTP with polling? Because checking every 2 seconds would drain the battery and waste bandwidth.
Protocol Comparison
Let's look at the alternatives we rejected:
1. Pure Polling (HTTP GET every 2 seconds)
Time Client Server
0s GET /messages?after=timestamp
<------ [Message 1, Message 2]
2s GET /messages?after=timestamp
<------ [empty]
4s GET /messages?after=timestamp
<------ [Message 3]
Problem: Wasteful. If messages arrive every 5 seconds on average, you're making two requests per message sent. Battery dies. Costs money on metered connections.
2. Long Polling
Time Client Server
0s GET /messages?after=timestamp (blocks)
<------ waits for response
2s [Message arrives] <------ [Message 1]
2s GET /messages?after=timestamp (blocks again)
<------ waits
5s [Message arrives] <------ [Message 2]
5s GET /messages?after=timestamp (blocks again)
Problem: Still wasteful on the connection overhead. Each message requires a new HTTP request, which means new TCP handshake, SSL negotiation, headers - expensive.
3. Server-Sent Events (SSE)
SSE is like WebSocket but one-way: the server can push to the client, but the client can't push to the server over the same connection.
Client: GET /events (holds open)
Server: [pushes event 1]
Server: [pushes event 2]
Client: POST /messages (separate connection)
Problem: We'd still need a separate HTTP connection for sending. Plus, SSE is mostly for server-to-client; browser support is better than mobile, and it's not ideal for a persistent two-way conversation.
4. Pure WebSocket
We could send messages over WebSocket too. Why don't we? Reasons: HTTP is stateless and familiar. If the WebSocket dies, sending fails obviously. With HTTP, retries and idempotency are well-understood. For writes, the simplicity is worth the extra connection.
Our Hybrid Approach
The client maintains two connections:
- WebSocket: Connected at all times (or reconnects on failure). Receives messages and real-time events.
- HTTP: Used only when sending or syncing. Closed after each request completes.
HTTP Endpoints
Send a Message
POST /api/v1/messages
Authorization: Bearer {token}
Content-Type: application/json
{
"toUserId": "bob_123",
"content": "Hey, how are you?",
"requestId": "alice_msg_1712102400123"
}
The requestId is crucial. It's an idempotency key generated locally (usually timestamp + random). If the request times out and Alice's app retries, the server sees the same requestId and knows it's a duplicate. It returns the same response without creating two messages.
Response (200 OK):
{
"messageId": "msg_abc123",
"status": "SENT",
"createdAt": "2026-04-02T15:30:45Z"
}
Sync Messages on App Open
GET /api/v1/conversations/{conversationId}/messages?lastSyncId=msg_xyz&limit=50
Authorization: Bearer {token}
This endpoint lets the phone catch up when the app opens or after a long disconnection. We pass the ID of the last message we saw locally, and the server returns everything newer.
Response:
{
"messages": [
{
"messageId": "msg_def456",
"fromUserId": "bob_123",
"toUserId": "alice_123",
"content": "I'm good, you?",
"status": "DELIVERED",
"createdAt": "2026-04-02T15:31:20Z"
},
{
"messageId": "msg_ghi789",
"fromUserId": "bob_123",
"toUserId": "alice_123",
"content": "Want to grab coffee?",
"status": "DELIVERED",
"createdAt": "2026-04-02T15:32:10Z"
}
],
"hasMore": false
}
Get Conversation List
GET /api/v1/conversations?limit=20&offset=0
Authorization: Bearer {token}
Returns the user's list of active conversations, sorted by most recent first.
Response:
{
"conversations": [
{
"conversationId": "conv_123",
"otherUserId": "bob_123",
"otherUserName": "Bob",
"otherUserAvatar": "https://...",
"lastMessage": "Want to grab coffee?",
"lastMessageTimestamp": "2026-04-02T15:32:10Z",
"unreadCount": 2,
"otherUserOnline": true
}
]
}
WebSocket Events
Once the WebSocket connection is established, the server can push these events to the client:
new_message
{
"type": "new_message",
"payload": {
"messageId": "msg_xyz",
"fromUserId": "bob_123",
"toUserId": "alice_123",
"content": "Let's meet at 3pm",
"createdAt": "2026-04-02T15:35:00Z"
}
}
When: A message arrives for this user. What the app does: Saves to local database, updates the UI, sends a delivery receipt back.
message_read
{
"type": "message_read",
"payload": {
"messageId": "msg_abc123",
"readBy": "bob_123",
"readAt": "2026-04-02T15:36:30Z"
}
}
When: The recipient has opened the conversation and the message is now visible on screen. What the app does: Updates the message status from DELIVERED to READ in the local database.
typing_indicator
{
"type": "typing_indicator",
"payload": {
"userId": "bob_123",
"isTyping": true
}
}
When: Bob is composing a message (and the app sends a signal while his fingers are on the keyboard).
What the app does: Shows "Bob is typing..." below the message input. Hides it when the signal says isTyping: false.
status_update
{
"type": "status_update",
"payload": {
"userId": "bob_123",
"isOnline": true,
"lastSeenAt": "2026-04-02T15:37:00Z"
}
}
When: Bob comes online or goes offline. What the app does: Updates the online indicator next to his name.
Data Models
Let's define the core entities. Here's what they look like in both languages:
Kotlin
// A single message
data class Message(
val messageId: String,
val fromUserId: String,
val toUserId: String,
val content: String,
val status: MessageStatus, // PENDING, SENT, DELIVERED, READ
val createdAt: Instant,
val readAt: Instant? = null
)
enum class MessageStatus {
PENDING, // Created locally, not yet sent
SENT, // Sent to server, waiting for delivery
DELIVERED, // Arrived on recipient's device
READ // Recipient opened the message
}
// A conversation between two users
data class Conversation(
val conversationId: String,
val otherUserId: String,
val otherUserName: String,
val otherUserAvatar: String,
val lastMessage: String,
val lastMessageTimestamp: Instant,
val unreadCount: Int,
val otherUserOnline: Boolean
)
// A user in the system
data class User(
val userId: String,
val name: String,
val avatarUrl: String,
val lastSeenAt: Instant? = null
)
Swift
// A single message
struct Message: Codable {
let messageId: String
let fromUserId: String
let toUserId: String
let content: String
let status: MessageStatus
let createdAt: Date
let readAt: Date?
enum CodingKeys: String, CodingKey {
case messageId, fromUserId, toUserId, content, status, createdAt, readAt
}
}
enum MessageStatus: String, Codable {
case pending // Created locally, not yet sent
case sent // Sent to server, waiting for delivery
case delivered // Arrived on recipient's device
case read // Recipient opened the message
}
// A conversation between two users
struct Conversation: Codable {
let conversationId: String
let otherUserId: String
let otherUserName: String
let otherUserAvatar: String
let lastMessage: String
let lastMessageTimestamp: Date
let unreadCount: Int
let otherUserOnline: Bool
}
// A user in the system
struct User: Codable {
let userId: String
let name: String
let avatarUrl: String
let lastSeenAt: Date?
}
Message Status Lifecycle
Every message goes through a predictable journey:
User sends message
|
v
PENDING (stored locally, not yet sent to server)
|
v (HTTP request succeeds)
SENT (server received it and assigned a messageId)
|
v (server delivers to recipient's device)
DELIVERED (recipient's phone has the message in database)
|
v (recipient opens conversation and reads)
READ (recipient's app sent us a read receipt)
This progression happens automatically. The app doesn't ask the user for permission; it's just state that flows through as events arrive.
The Idempotency Key Pattern
🔗 Building on Previous: In the Social Feed case study, we discussed pagination cursors for reliability. Here we're using a similar principle with the
requestId: each request has a unique identifier so the server can detect retries.
When you send a message, include a requestId:
val messageId = generateLocalId() // Something like "alice_1712102400123"
val requestId = "${currentUserId}_${messageId}"
val request = SendMessageRequest(
toUserId = "bob_123",
content = "Hey!",
requestId = requestId
)
If the HTTP request times out, your app retries with the same requestId. The server sees it, knows it's a duplicate, and returns the same successful response. This prevents the embarrassing situation where you send "Hey" three times because the network was flaky.
> 🎯 Interviewer's Perspective
An interviewer asking about API design for chat will want to see that you understand:
-
Why HTTP and WebSocket together: Don't just say "WebSocket for real-time." Explain the trade-offs and why HTTP for writes simplifies things.
-
Idempotency: Show that you know messages can be sent multiple times (network failures), and you have a strategy to prevent duplicates.
-
Message ordering: You'll probably explain that the server assigns IDs, but think about what happens if messages arrive out of order on the client (they shouldn't, but be prepared).
-
Scalability of WebSocket: Mention that a chat server with millions of users needs a load balancer and connection manager. You don't need to design it, but show you're thinking about it.
> ❌ Common Pitfall
A common mistake is saying "We'll use WebSocket for everything." While theoretically possible, it overcomplicates the sending logic and loses the benefits of HTTP's well-established retry semantics. Hybrid approaches are common in production systems.
What's Next
We've defined what the client talks to. Now let's zoom out and see how all the pieces fit together: the local database, the repositories, the ViewModels, the WebSocket manager. This is where the real architecture lives.