Your chat screen reconnects after a dropped network, and now Message 47 shows up twice. Nobody touched the UI code. The bug is almost always in how the client resumes after a WebSocket disconnect — and it's one of the first things interviewers probe on an "design a chat app" system design question.
The naive reconnect
Here's the version most chat clients start with:
class ChatConnection(private val scope: CoroutineScope, private val dao: MessageDao) {
private var backoffMs = 1000L
fun connect() {
socket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
backoffMs = 1000L
// "we're connected, just wait for messages"
}
override fun onMessage(ws: WebSocket, text: String) {
scope.launch { dao.insert(parseMessage(text)) }
}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
scope.launch {
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(30_000)
connect()
}
}
})
}
}
This looks reasonable, and it works perfectly in the happy path — stay connected, receive messages, insert them. The bug only shows up under exactly the condition a chat app spends a lot of its life in: the connection drops and comes back.
Why duplicates happen
A WebSocket reconnect is not a resume — it's a brand new connection. The server has no idea what the client already has. Two very ordinary scenarios both produce duplicates with the code above:
- The server sent a message right as the connection dropped. The client never got it, reconnects, and the server (reasonably) assumes it needs to replay something — if your backend's replay logic is at all generous about the window, it can resend messages the client actually did receive just before the drop.
- The client's own
onFailurehandler fires more than once in quick succession (a flaky network can do this), and two reconnect attempts both succeed, each pulling a backlog.
dao.insert() on a plain, non-deduplicating table just appends both copies. The database doesn't know Message 47 already exists — because nothing told it what "the same message" even means.
The fix: a server-assigned ID the client resumes from, and a database that rejects duplicates
Two changes, and they're both small:
1. Every message gets a durable ID, and the client remembers the last one it has.
override fun onOpen(ws: WebSocket, response: Response) {
backoffMs = 1000L
scope.launch {
val lastId = dao.lastMessageId() // read from the local DB, not an in-memory variable
ws.send("""{"resumeFrom": $lastId}""")
}
}
The client isn't saying "send me whatever you think I'm missing" — it's saying exactly which ID it already has, read from the database that survives process death, not an in-memory field that resets on every reconnect.
2. The insert is idempotent, keyed by that ID.
@Dao
interface MessageDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(message: MessageEntity): Long
@Query("SELECT MAX(serverId) FROM messages")
suspend fun lastMessageId(): Long
}
OnConflictStrategy.IGNORE on a serverId primary key means a redelivered message is a silent no-op, not a duplicate row. This is the part that actually matters — the resume protocol reduces how often duplicates get sent, but the database is what makes a duplicate harmless when (not if) one slips through anyway. Relying on the resume logic alone to be perfect is how this bug keeps coming back every time someone touches the reconnect code.
Why this is a system design question, not just a bug
"Design a chat app" interviews love this because the happy-path client is genuinely easy to write — almost every candidate gets that part right in the first two minutes. What separates a senior answer is naming the failure mode before being asked: what happens during the disconnect window, and what guarantees the design makes about that window specifically. A design that silently assumes "the socket delivers everything exactly once" hasn't actually designed for an unreliable network — it's designed for a network that doesn't exist.
The same pattern — a durable position the client resumes from, plus an idempotent write — shows up again almost anywhere a mobile client talks to a server over an unreliable connection: background sync, push notification delivery, live location updates. Once you've built it once, you start recognizing it everywhere.
I put together a full question bank of prompts like this one — 30 "design X" questions that come up in real Android system design interviews, each with a worked answer and a Kotlin code blueprint, not just bullet points. The free sample on GitHub walks through designing an offline-first note-taking app (the Google Keep-style one) end to end: github.com/yogirana5557/android-digital-products/tree/main/android-system-design-interview-bank.
The full set — including chat/reconnect, live location sharing, caching, app architecture at scale, and five full mock walkthroughs (Instagram's feed, a WhatsApp-style chat app, Dropbox-style sync, Uber-style tracking, Spotify-style offline downloads) — is a ₹599 PDF on Gumroad: yogirana.gumroad.com/l/android-system-design-interview-bank.
Top comments (0)