In short
- In an offline-first Android app, the local Room database is the single source of truth and the UI only observes it.
- An outbox table records every pending write as an operation with an idempotency key, so a retry can never apply the same change twice.
- WorkManager guarantees that sync work eventually runs under its constraints; it does not guarantee when, and Doze can defer it for a long time.
- Last-write-wins is defensible only with server-assigned versions and a low conflict rate; field-level merge is the usual staff answer for notes.
- Staff candidates name what they would measure: sync success rate, oldest pending operation age, and conflict rate.
The most common prompt in an Android system design interview at senior and staff level is some version of "design an app that works offline and syncs." A notes app. A field inspection app for technicians in basements. Almost every candidate draws the same boxes: Room, a repository, a network layer, WorkManager. The boxes are the entry fee. The score comes from what happens when I push on them.
I have run more than 500 technical interviews from the hiring side, and this prompt produces more no hire decisions at staff level than any coding question. Not because candidates do not know Room. Because they stop at the happy path, and every follow-up I ask lives off it. Here is the prompt the way a staff candidate answers it, and the follow-ups that break the average answer.
The clarifying questions that change the design
A senior candidate asks clarifying questions because they were told to. A staff candidate asks the ones whose answers change the architecture, and says out loud which part each answer moves. Four of them do almost all the work.
- How often do two writers touch the same record? A personal notes app sees conflicts rarely. A shared inspection checklist edited by a crew sees them daily. This single number decides your conflict strategy.
- How big is the data, and how long can a device stay offline? Ten thousand small notes fit comfortably in SQLite. Photos do not belong in the same queue as text edits. A device offline for three weeks will come back to a server that has moved on.
- Is one user on several devices? If yes, you have conflicts even with a single user, and "the user is always right" stops being an answer.
- When two edits disagree, who wins? This is a product question, not an engineering one. In regulated work, the answer is often "nobody wins silently," which means you keep both and surface the conflict.
What I write down is not the list. It is whether you connect each answer to a decision. "If conflicts are rare and it is single-user, I will start with server-versioned last-write-wins. If it is a shared checklist, I will merge at the field level." That sentence tells me you design from constraints rather than from a template.
Offline-first means Room is the single source of truth
Offline-first means the app reads and writes a local database as its primary store, and the network is a background process that reconciles that store with the server. The UI never waits on the network. It observes Room through a Flow, and Room emits again whenever a table the query reads from changes.
The consequence candidates miss: the network layer never pushes data into the UI. The sync worker writes server results into Room, and the UI updates because it was already observing. One path for data to reach the screen. When I ask "where does a freshly synced note come from on screen," the correct answer is "the same query that showed it before." Candidates who answer with a callback or a second StateFlow fed by Retrofit have two sources of truth, and I will find the bug between them in the next follow-up.
The local write and the record of the pending operation must happen in one transaction. If the app dies between saving the note and recording that it needs to sync, the edit exists locally and never reaches the server. Nothing will ever retry it, because nothing knows it is there.
The outbox table and idempotency keys
An outbox is a local table of pending operations, written in the same transaction as the change it describes, and drained by the sync worker in order. It turns "the network was down" from a lost write into a delayed one.
@Entity(
tableName = "outbox",
indices = [Index("entityId"), Index("createdAt")]
)
data class PendingOp(
@PrimaryKey val opId: String, // UUID, doubles as idempotency key
val entityId: String,
val type: OpType, // CREATE, UPDATE, DELETE
val payload: String, // JSON of the changed fields only
val baseVersion: Long?, // server version the edit was based on
val createdAt: Long,
val attempts: Int = 0,
val lastError: String? = null
)
@Dao
interface NoteDao {
@Upsert suspend fun upsertNote(note: NoteEntity)
@Insert suspend fun enqueue(op: PendingOp)
@Transaction
suspend fun saveLocally(note: NoteEntity, op: PendingOp) {
upsertNote(note)
enqueue(op)
}
@Query("SELECT * FROM outbox ORDER BY createdAt LIMIT :n")
suspend fun nextBatch(n: Int): List<PendingOp>
}Three fields carry the design. opId is generated on the device at the moment of the edit, not at send time, so every retry of that edit carries the same key. payload holds changed fields rather than the whole note, which is what makes field-level merge possible later. baseVersion records what the user was looking at when they edited, which is what lets the server detect a conflict instead of guessing.
The server side is where idempotency becomes real. A request can succeed on the server and time out on the way back. The device retries. Without dedupe, a create becomes two notes. The server keeps a table of processed operation ids and applies each one at most once.
// Server side, inside one database transaction
fun apply(op: IncomingOp, userId: String): OpResult {
val inserted = db.update(
"""INSERT INTO processed_ops (op_id, user_id)
VALUES (?, ?) ON CONFLICT (op_id) DO NOTHING""",
op.opId, userId
)
if (inserted == 0) return storedResultFor(op.opId)
val current = notes.find(op.entityId)
if (current != null && op.baseVersion != current.version) {
return resolveConflict(op, current)
}
val saved = notes.upsert(op.entityId, op.fields, current)
return OpResult.Applied(saved.version).also {
storeResult(op.opId, it)
}
}The detail I listen for: a duplicate returns the stored result of the first attempt, not an error. If the retry gets a 409 for its own earlier success, the client marks a successful edit as failed. The server also assigns version, so ordering never depends on the phone's clock.
WorkManager: what it guarantees and what it does not
Everyone reaches for WorkManager, and they are right to. It persists work across process death and reboots, respects constraints like network availability, and retries with backoff. The staff signal is knowing where the guarantee ends.
class SyncWorker(
ctx: Context,
params: WorkerParameters,
private val repo: SyncRepository
) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
return when (val r = repo.pushThenPull(batchSize = 50)) {
is SyncOutcome.Done -> Result.success()
is SyncOutcome.Transient ->
if (runAttemptCount < 8) Result.retry()
else Result.failure()
is SyncOutcome.AuthExpired -> Result.failure()
}
}
}
fun scheduleSync(context: Context) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"sync", ExistingWorkPolicy.APPEND_OR_REPLACE, request
)
}Unique work matters more than the constraints. Without it, ten quick edits enqueue ten workers draining the same outbox concurrently, and your ordering is gone. APPEND_OR_REPLACE chains a new run after the current one, so an edit made during a sync still gets pushed, and a failed chain does not block future work. The worker needs a custom WorkerFactory to receive the repository, or Hilt's @HiltWorker; say so briefly and move on.
What WorkManager does not promise: exact timing. A constraint-satisfied worker can still be deferred by Doze, App Standby buckets, and battery saver. Periodic work has a 15 minute minimum interval and drifts. A worker also has a bounded execution window, so a sync that tries to upload three weeks of photos in one run will be stopped. If a user taps "sync now" and expects it immediately, that is expedited work or a foreground path, and it has quotas of its own. Candidates who say "WorkManager runs it when the network comes back" have stated the best case as the contract.
Conflict resolution: last-write-wins, field merge, or CRDTs
Conflict resolution is the rule the system applies when two edits were made against the same base version. There are three families, and each is defensible under specific constraints.
Last-write-wins with server versions
Simple, predictable, and lossy. Defensible for single-user data with rare conflicts, or for fields where the latest value is the only meaningful one, such as a status toggle. It is only acceptable when "last" means the order the server accepted writes. The moment a candidate compares device timestamps, I ask about a phone whose clock is set two days ahead, and the answer usually falls apart.
Field-level merge
Because the outbox stores changed fields and a base version, the server can apply edits that touched different fields and flag only true overlaps. One phone changes the title, the other changes the body: both survive. This is the answer I expect from a staff candidate for notes or inspection forms. It needs per-field change tracking, which is cheap if you designed the payload for it from the start and painful to retrofit.
CRDT-style merging
Conflict-free replicated data types merge concurrent edits deterministically without a coordinator. They are the right tool for collaborative text where two people type in the same paragraph. They are a heavy tool for a notes app with one author. Proposing a CRDT without being asked about concurrent character-level editing reads as reaching for the most impressive answer, not the one the constraints call for. Naming it, and saying why you would not start there, scores better.
Asked in the loopThe user edits the same note on two phones while both are offline. Both reconnect. What happens?
The last one to sync wins, so the user sees whichever phone reconnected second. That is usually fine because it is the same user.
Both ops carry the same base version. The first to arrive applies and bumps the version. The second arrives with a stale base, so the server compares changed fields. If they touched different fields, it merges and returns the new version. If both changed the body, I do not pick silently: I keep the server copy, save the losing edit as a conflict copy the user can see, and the second phone pulls the result. Losing a user's text without telling them is the one outcome I design against.
The weak answer is not wrong for every product. It fails because it never asks whether silent loss is acceptable for this one.
Partial sync, change feeds, and migrations with pending writes
Pulling is its own design. Refetching everything on each sync burns battery and data and does not scale past a few hundred records. The staff answer is a change feed: the server returns changes since an opaque sync token, in pages, and the client stores the new token only after the page is committed to Room. Store it before the commit and a crash skips a page forever. Deletes must appear in the feed as tombstones, or a note deleted on the web lives on every phone.
Push before pull, or reconcile carefully. If you pull first, a server copy can overwrite a local edit still sitting in the outbox. The simple rule: when applying pulled changes, skip or rebase any entity that has pending operations, and let the push resolve it.
Then the follow-up almost nobody prepares for: you ship a schema change while users have pending writes. The Room migration upgrades the notes table, but the outbox holds JSON payloads written against the old shape, and the server may have moved its API version too. Either the migration rewrites queued payloads into the new shape, or each op records the schema version it was written with and the sync layer upgrades it before sending. Deleting the outbox during migration to make it simple deletes user work. I have written no hire on candidates who proposed exactly that, because in fintech and regulated systems it is the class of bug that becomes an incident report.
Failure modes the follow-ups aim at
- App killed mid-sync. An op is removed from the outbox only after the server confirms it. The retry is safe because of the idempotency key. Pull tokens advance only after the Room transaction commits.
- Clock skew. Device time is used for display and local ordering of the outbox, never to decide which edit wins.
- Token expiry during sync. A 401 halfway through a batch should not become retries against the same dead token. Refresh once, behind a mutex so parallel calls do not all refresh. If refresh fails, stop, keep the outbox intact, and show a sign-in prompt. The pending writes wait.
- Poison operations. One op the server will always reject must not block the queue behind it forever. Cap attempts per op, move it to a dead-letter state, and tell the user.
Asked in the loopYou send a batch of 50 operations. The server accepts 30 and rejects 20. What does the client do?
Retry the whole batch. The server will reject the bad ones again and accept the rest, and since it is idempotent, the 30 are fine.
The response has to be per operation, not one status for the batch. The 30 accepted ops get deleted from the outbox with their new server versions written to Room. For the 20 rejections I split by cause. Validation errors are permanent: mark them failed, keep the local data, surface them. Conflicts go to the merge path. Transient errors stay queued. And if a rejected op is a create that later ops depend on, those later ops for the same entity must wait, or I send updates for a note the server never accepted.
Idempotency makes the weak answer safe, but it burns data and battery on every retry and never tells the user that 20 edits are stuck.
Battery, data budgets, and what you would measure
Staff candidates treat the sync engine as a production system with a budget. Batch small text ops together. Send photos separately, on unmetered networks when the product allows, with resumable uploads. Compress payloads. Do not schedule periodic sync when a push message from the server can trigger a pull only when something changed. Each of these is a sentence, and together they show you have owned an app whose battery complaints landed on your desk.
Then say what you would put on a dashboard. Three numbers cover most of it.
- Sync success rate per attempt and per day, split by error class, so an auth problem does not hide inside a network problem.
- Age of the oldest pending operation per device, reported as a distribution. The average hides the users stuck for a week, and those are the ones who churn.
- Conflict rate and how many conflicts reached the user. If the number is high, your merge granularity is wrong or your product has a collaboration feature nobody designed for.
Unprompted metrics are one of the clearest staff signals in a mobile design round. Staff Android roles at large US tech companies commonly pay $300k to $400k in total compensation, and system design is where that level gets decided. More on how those signals separate levels in senior or staff: the signals that move a mobile offer up a level.
How to practice this Android system design interview prompt
Reading an answer does not prepare you for the pushback. Say the design out loud in ten minutes, then have someone ask the two follow-ups above and one you did not expect. The gap between your first answer and your answer to the third follow-up is what the loop measures, a point I make at length in the follow-up is the interview.
Android Interview Training is built around that format: the question as it is asked, the answer that loses the point, the one that keeps it, and the follow-ups that come next, including sync, WorkManager and coroutines under pressure. The free Android sample shows the format before you decide anything.
Questions readers ask
© 2026 Mike Salari, Interview Runtime. All rights reserved. Quoting a short excerpt with a link to this page is welcome; republishing the article, in full or in part, is not.
