Android System Design Interview Questions
In an Android app, how do you handle data syncing when the network isn't stable?
Tier: CommonDifficulty: Medium
Design synchronization that keeps local app data and server data consistent when the connection repeatedly drops. A request may fail before reaching the server, or it may succeed on the server while the response never reaches the phone.
The problem
Keep pending local changes until their outcome is known, retry safely and retrieve server changes after reconnecting. Define progress and error states so the user knows what is saved locally and what has reached the server. Pending work should survive app restarts.
For example, Ana creates a task and the server stores it, but the acknowledgement is lost. The app retries the same logical creation with the same identifier. The server should recognize it and return the existing task rather than create a duplicate. This property is called idempotency.
Start with one type of record and an agreed conflict policy. The exercise covers persistent pending work, safe retries and reconciliation after gaps, rather than assuming a reliable connection for each operation.
Starting the design
An unstable network isn't the same problem as no network. It means requests intermittently fail, time out, or send partway before dropping. So the design has to assume any single attempt can fail, and make retrying safe rather than trying to prevent failure.
What I'd clarify first
- Are we syncing reads, writes, or both. A failed read just means stale data, but a failed write has to be retried safely or clearly surfaced as failed, never silently lost.
- Does sync need to survive the app being killed during an attempt, which pushes this toward
WorkManagerrather than an in memory retry loop tied to the app process. - Can the same data be edited from more than one device, which determines whether conflict resolution needs to be part of this design.
The approach
- Queue writes locally before attempting to send them. Every write goes into a local outbox table first, marked pending, and the UI reflects it optimistically. The actual network send is a separate step that can fail and retry without the user's action ever being at risk.
- Retry with exponential backoff, not a fixed interval. A flaky connection often needs a little time to recover, and retrying every second just wastes battery hitting the same failure. Backing off, a few seconds and then longer, up to a cap, gives the connection room to stabilize.
- Idempotent requests. Every write the client sends has to be safe to receive twice. An idempotency key on the request is what lets the backend recognize a retry of something it already processed, rather than applying the same change twice. Once retries are a normal part of the flow rather than a rare edge case, that key is the difference between a duplicate message and a correct one.
WorkManagerfor anything that has to survive the app dying. A network constrainedWorkRequestmakes sync resume once connectivity returns, even if the user closed the app. Enqueue it as unique work, otherwise two enqueues run two workers over the same outbox.- A delta pull for the other direction. The pull is a query keyed on a server issued cursor, so a reconnect asks for what changed since the last sync rather than everything. Deletes have to come back as tombstones, otherwise a record deleted on the server lives forever on the client.
How a sync cycle behaves
A write lands in the outbox and the UI shows it immediately. A background worker, constrained to run only with network available, picks up pending rows. It marks a row syncing before it sends, which claims the row so a second worker cannot send the same write. A success deletes the row. A transient failure puts it back to pending with a later attempt time, and a rejection marks it failed so the UI can show it with a retry. The worker then reports whether anything is still waiting, and that answer is what turns into a WorkManager retry.
There are two layers of backoff and that is deliberate. WorkManager backs off the worker, and the per entry schedule backs off individual rows, so one poison row does not hold up the batch behind it.
The code
Three files, and the backoff is the one to read. Exponential, capped, and jittered, and it is the jitter that gets left out. Every device that lost the same cell tower reconnects in the same second, so a fleet without jitter retries in one synchronised wave against your own backend. The random value is a parameter rather than something the class reaches for, which is what makes the schedule testable.
The engine has three outcomes and not two. Lumping a rejected payload in with a lost connection means a write the server will never accept is retried forever from the front of the queue. A rejection is marked failed instead, and the DAO has a query for those rows so the UI can offer a retry. The flush is bounded, because a device offline for three days comes back with thousands of rows, and firing all of them the instant the radio wakes drops the connection you just got back.
What the flush returns is the part worth getting right. If every send in a batch failed, those rows are all rescheduled into the future, so asking the queue "is anything due right now" answers no. The engine returns true because it rescheduled something, and the worker retries instead of reporting a success it did not have.
Java
com.androidinterview.sync.engine.SyncEngine.java
package com.androidinterview.sync.engine;
import com.androidinterview.sync.outbox.Backoff;
import com.androidinterview.sync.outbox.OutboxEntry;
import com.androidinterview.sync.outbox.OutboxEntry.OutboxDao;
import java.util.function.LongSupplier;
// One flush. A WorkManager worker with a network constraint calls this and
// reports whether to retry, so nothing here reimplements wait for a good time
// to sync, and the worker resumes after the app is killed.
public final class SyncEngine {
// Three outcomes, because two is the mistake. Lumping a rejected payload
// in with a lost connection means a write the server will never accept is
// retried forever, and it is at the front of the queue.
public enum SendResult {
ACCEPTED, TRANSIENT_FAILURE, REJECTED
}
public interface SyncApi {
SendResult send(OutboxEntry entry);
}
private final OutboxDao dao;
private final SyncApi api;
private final Backoff backoff;
private final int batchSize;
private final LongSupplier now;
public SyncEngine(OutboxDao dao, SyncApi api, Backoff backoff, int batchSize, LongSupplier now) {
this.dao = dao;
this.api = api;
this.backoff = backoff;
this.batchSize = batchSize;
this.now = now;
}
// Returns whether anything is still waiting, which is what the worker turns
// into a retry or a success. The clock is injected for the same reason the
// random is, so a test can drive a whole schedule without sleeping.
public boolean flush(double random) {
long startedAt = now.getAsLong();
int rescheduled = 0;
// Bounded on purpose. A device offline for three days comes back with
// a queue of thousands, and firing all of them the instant the radio
// wakes is how you drop the connection you just got back.
for (OutboxEntry entry : dao.due(startedAt, batchSize)) {
// The row is claimed before it is sent, not after. due returns
// PENDING rows only, so this is what stops a second worker, or a
// foreground retry, sending the same write at the same time. A row
// left SYNCING because the process died is swept back to PENDING by
// an age check on the next run.
dao.markSyncing(entry.id());
switch (api.send(entry)) {
case ACCEPTED -> dao.delete(entry.id());
// Back in the queue, later each time. The row is untouched
// otherwise, so the same idempotency key goes out again and the
// server recognises the retry rather than applying it twice.
case TRANSIENT_FAILURE -> {
dao.reschedule(
entry.id(),
entry.attempt() + 1,
startedAt + backoff.delayMillis(entry.attempt(), random));
rescheduled++;
}
// The server will never accept this. Retrying is pointless and
// it blocks everything behind it, so it is marked failed and
// surfaced through dao.failed(). A silently dropped write is
// the worst outcome available here, worse than a visible
// failure with a retry.
case REJECTED -> dao.markFailed(entry.id());
}
}
// Anything rescheduled means come back, full stop. Asking the queue
// instead would answer no, because every row this flush rescheduled is
// now due in the future, so a batch where everything failed would be
// reported to WorkManager as a success and never retried. The second
// check reads the clock again rather than reusing startedAt, since a
// slow flush may have made other rows due while it ran.
return rescheduled > 0 || !dao.due(now.getAsLong(), 1).isEmpty();
}
}
package com.androidinterview.sync.engine;
import com.androidinterview.sync.outbox.Backoff;
import com.androidinterview.sync.outbox.OutboxEntry;
import com.androidinterview.sync.outbox.OutboxEntry.OutboxDao;
import java.util.function.LongSupplier;
public final class SyncEngine {
public enum SendResult {
ACCEPTED, TRANSIENT_FAILURE, REJECTED
}
public interface SyncApi {
SendResult send(OutboxEntry entry);
}
private final OutboxDao dao;
private final SyncApi api;
private final Backoff backoff;
private final int batchSize;
private final LongSupplier now;
public SyncEngine(OutboxDao dao, SyncApi api, Backoff backoff, int batchSize, LongSupplier now) {
this.dao = dao;
this.api = api;
this.backoff = backoff;
this.batchSize = batchSize;
this.now = now;
}
public boolean flush(double random) {
long startedAt = now.getAsLong();
int rescheduled = 0;
for (OutboxEntry entry : dao.due(startedAt, batchSize)) {
dao.markSyncing(entry.id());
switch (api.send(entry)) {
case ACCEPTED -> dao.delete(entry.id());
case TRANSIENT_FAILURE -> {
dao.reschedule(
entry.id(),
entry.attempt() + 1,
startedAt + backoff.delayMillis(entry.attempt(), random));
rescheduled++;
}
case REJECTED -> dao.markFailed(entry.id());
}
}
return rescheduled > 0 || !dao.due(now.getAsLong(), 1).isEmpty();
}
}
com.androidinterview.sync.outbox.Backoff.java
package com.androidinterview.sync.outbox;
// Exponential, capped, and jittered. All three matter and the third is the one
// that gets left out.
//
// A flaky connection needs time to recover, so retrying every second wastes
// battery hitting the same failure. The cap exists because doubling without
// one puts the eighteenth attempt a day away. And the jitter exists because
// every device that lost the same cell tower reconnects in the same second,
// so an unjittered fleet retries in one synchronised wave, which is a small
// self inflicted denial of service against your own backend.
public final class Backoff {
private final long baseMillis;
private final long capMillis;
public Backoff(long baseMillis, long capMillis) {
this.baseMillis = baseMillis;
this.capMillis = capMillis;
}
// The random is passed in rather than taken here, so the whole schedule is
// testable. WorkManager applies the same shape to the worker itself, and
// this is the per entry schedule inside it.
// Equal jitter, so the delay lands between half the capped value and all of
// it. random is expected in the range zero up to one, the shape every
// standard random source already gives you.
public long delayMillis(int attempt, double random) {
long doubled = baseMillis << Math.min(attempt, 16);
long capped = Math.min(doubled, capMillis);
return (long) (capped * (0.5 + random / 2));
}
}
package com.androidinterview.sync.outbox;
public final class Backoff {
private final long baseMillis;
private final long capMillis;
public Backoff(long baseMillis, long capMillis) {
this.baseMillis = baseMillis;
this.capMillis = capMillis;
}
public long delayMillis(int attempt, double random) {
long doubled = baseMillis << Math.min(attempt, 16);
long capped = Math.min(doubled, capMillis);
return (long) (capped * (0.5 + random / 2));
}
}
com.androidinterview.sync.outbox.OutboxEntry.java
package com.androidinterview.sync.outbox;
import java.util.List;
// One pending write, as a row in a Room table rather than a job in memory. The
// row is what survives the process dying, and surviving the process dying is
// most of what this question is about.
//
// The idempotency key is generated once, when the write is queued, and never
// regenerated on a retry. Without it a retry is a second write, which on an
// unstable network is not a rare edge case but the normal path.
//
// The payload is text, so a photo upload keeps the file on disk and puts the
// path in this column. A base64 image in a Room row is a database that stops
// opening quickly.
public record OutboxEntry(
long id,
String idempotencyKey,
String endpoint,
String payload,
int attempt,
long nextAttemptAt,
Status status) {
public enum Status {
PENDING, SYNCING, FAILED
}
// Room sits behind this. due is an indexed query on status and time, so a
// wake up costs one query rather than a scan of everything ever queued.
//
// due returns PENDING rows only. That is what makes markSyncing a claim
// rather than a label, because a second worker over the same outbox cannot
// pick up a row this one is already sending. The outbox is unordered, so
// two writes to the same record have to be commutative, or the query has to
// hold back an entry whose record already has an older row waiting.
public interface OutboxDao {
List<OutboxEntry> due(long nowMillis, int limit);
void markSyncing(long id);
void delete(long id);
// Back to PENDING, later. The attempt count and the next attempt time
// are the whole schedule, so nothing has to be held in memory.
void reschedule(long id, int attempt, long nextAttemptAt);
void markFailed(long id);
// The two the UI needs, and the reason FAILED is a state rather than a
// deletion. A write the server refused is shown with a retry button
// instead of disappearing without the user ever knowing.
List<OutboxEntry> failed();
void retry(long id);
}
}
package com.androidinterview.sync.outbox;
import java.util.List;
public record OutboxEntry(
long id,
String idempotencyKey,
String endpoint,
String payload,
int attempt,
long nextAttemptAt,
Status status) {
public enum Status {
PENDING, SYNCING, FAILED
}
public interface OutboxDao {
List<OutboxEntry> due(long nowMillis, int limit);
void markSyncing(long id);
void delete(long id);
void reschedule(long id, int attempt, long nextAttemptAt);
void markFailed(long id);
List<OutboxEntry> failed();
void retry(long id);
}
}
Kotlin
com.androidinterview.sync.engine.SyncEngine.kt
package com.androidinterview.sync.engine
import com.androidinterview.sync.outbox.Backoff
import com.androidinterview.sync.outbox.OutboxDao
import com.androidinterview.sync.outbox.OutboxEntry
// Three outcomes, because two is the mistake. Lumping a rejected payload in
// with a lost connection means a write the server will never accept is retried
// forever, and it sits at the front of the queue while it happens.
enum class SendResult { ACCEPTED, TRANSIENT_FAILURE, REJECTED }
fun interface SyncApi {
suspend fun send(entry: OutboxEntry): SendResult
}
// One flush. A WorkManager worker with a network constraint calls this and
// turns the answer into a retry or a success, so nothing here reimplements
// wait for a good time to sync, and the worker resumes after the app is killed.
//
// The clock is injected for the same reason the random is, so a test can drive
// a whole schedule without sleeping.
class SyncEngine(
private val dao: OutboxDao,
private val api: SyncApi,
private val backoff: Backoff,
private val batchSize: Int,
private val now: () -> Long,
) {
// Returns whether anything is still waiting.
suspend fun flush(random: Double): Boolean {
val startedAt = now()
var rescheduled = 0
// Bounded on purpose. A device offline for three days comes back with
// a queue of thousands, and firing all of them the instant the radio
// wakes is how you drop the connection you just got back.
dao.due(startedAt, batchSize).forEach { entry ->
// The row is claimed before it is sent, not after. due returns
// PENDING rows only, so this is what stops a second worker, or a
// foreground retry, sending the same write at the same time. A row
// left SYNCING because the process died is swept back to PENDING by
// an age check on the next run.
dao.markSyncing(entry.id)
when (api.send(entry)) {
SendResult.ACCEPTED -> dao.delete(entry.id)
// Back in the queue, later each time. The row is otherwise
// untouched, so the same idempotency key goes out again and the
// server recognises the retry rather than applying it twice.
SendResult.TRANSIENT_FAILURE -> {
dao.reschedule(
entry.id,
entry.attempt + 1,
startedAt + backoff.delayMillis(entry.attempt, random),
)
rescheduled++
}
// The server will never accept this. Retrying is pointless and
// it blocks everything behind it, so it is marked failed and
// surfaced through dao.failed(). A silently dropped write is
// the worst outcome available, worse than a visible failure
// with a retry button.
SendResult.REJECTED -> dao.markFailed(entry.id)
}
}
// Anything rescheduled means come back, full stop. Asking the queue
// instead would answer no, because every row this flush rescheduled is
// now due in the future, so a batch where everything failed would be
// reported to WorkManager as a success and never retried. The second
// check reads the clock again rather than reusing startedAt, since a
// slow flush may have made other rows due while it ran.
return rescheduled > 0 || dao.due(now(), 1).isNotEmpty()
}
}
package com.androidinterview.sync.engine
import com.androidinterview.sync.outbox.Backoff
import com.androidinterview.sync.outbox.OutboxDao
import com.androidinterview.sync.outbox.OutboxEntry
enum class SendResult { ACCEPTED, TRANSIENT_FAILURE, REJECTED }
fun interface SyncApi {
suspend fun send(entry: OutboxEntry): SendResult
}
class SyncEngine(
private val dao: OutboxDao,
private val api: SyncApi,
private val backoff: Backoff,
private val batchSize: Int,
private val now: () -> Long,
) {
suspend fun flush(random: Double): Boolean {
val startedAt = now()
var rescheduled = 0
dao.due(startedAt, batchSize).forEach { entry ->
dao.markSyncing(entry.id)
when (api.send(entry)) {
SendResult.ACCEPTED -> dao.delete(entry.id)
SendResult.TRANSIENT_FAILURE -> {
dao.reschedule(
entry.id,
entry.attempt + 1,
startedAt + backoff.delayMillis(entry.attempt, random),
)
rescheduled++
}
SendResult.REJECTED -> dao.markFailed(entry.id)
}
}
return rescheduled > 0 || dao.due(now(), 1).isNotEmpty()
}
}
com.androidinterview.sync.outbox.Outbox.kt
package com.androidinterview.sync.outbox
enum class Status { PENDING, SYNCING, FAILED }
// One pending write, as a row in a Room table rather than a job in memory. The
// row is what survives the process dying, and surviving the process dying is
// most of what this question is about.
//
// The idempotency key is generated once, when the write is queued, and never
// regenerated on a retry. Without it a retry is a second write, which on an
// unstable network is not a rare edge case but the normal path.
//
// The payload is text, so a photo upload keeps the file on disk and puts the
// path in this column. A base64 image in a Room row is a database that stops
// opening quickly.
data class OutboxEntry(
val id: Long,
val idempotencyKey: String,
val endpoint: String,
val payload: String,
val attempt: Int = 0,
val nextAttemptAt: Long = 0,
val status: Status = Status.PENDING,
)
// Room sits behind this. due is an indexed query on status and time, so a wake
// up costs one query rather than a scan of everything ever queued.
//
// due returns PENDING rows only. That is what makes markSyncing a claim rather
// than a label, because a second worker over the same outbox cannot pick up a
// row this one is already sending. The outbox is unordered, so two writes to
// the same record have to be commutative, or the query has to hold back an
// entry whose record already has an older row waiting.
interface OutboxDao {
suspend fun due(nowMillis: Long, limit: Int): List<OutboxEntry>
suspend fun markSyncing(id: Long)
suspend fun delete(id: Long)
// Back to PENDING, later. The attempt count and the next attempt time are
// the whole schedule, so nothing has to be held in memory.
suspend fun reschedule(id: Long, attempt: Int, nextAttemptAt: Long)
suspend fun markFailed(id: Long)
// The two the UI needs, and the reason FAILED is a state rather than a
// deletion. A write the server refused is shown with a retry button instead
// of disappearing without the user ever knowing.
suspend fun failed(): List<OutboxEntry>
suspend fun retry(id: Long)
}
// Exponential, capped, and jittered. All three matter and the third is the one
// that gets left out.
//
// A flaky connection needs time to recover, so retrying every second wastes
// battery hitting the same failure. The cap exists because doubling without
// one puts the eighteenth attempt a day away. And the jitter exists because
// every device that lost the same cell tower reconnects in the same second, so
// an unjittered fleet retries in one synchronised wave, a small self inflicted
// denial of service against your own backend.
//
// The random is a parameter rather than taken here, so the whole schedule is
// testable. WorkManager applies the same shape to the worker, and this is the
// per entry schedule inside it.
class Backoff(private val baseMillis: Long, private val capMillis: Long) {
// Equal jitter, so the delay lands between half the capped value and all of
// it. random is expected in the range zero up to one, the shape every
// standard random source already gives you.
fun delayMillis(attempt: Int, random: Double): Long {
val capped = minOf(baseMillis shl minOf(attempt, 16), capMillis)
return (capped * (0.5 + random / 2)).toLong()
}
}
package com.androidinterview.sync.outbox
enum class Status { PENDING, SYNCING, FAILED }
data class OutboxEntry(
val id: Long,
val idempotencyKey: String,
val endpoint: String,
val payload: String,
val attempt: Int = 0,
val nextAttemptAt: Long = 0,
val status: Status = Status.PENDING,
)
interface OutboxDao {
suspend fun due(nowMillis: Long, limit: Int): List<OutboxEntry>
suspend fun markSyncing(id: Long)
suspend fun delete(id: Long)
suspend fun reschedule(id: Long, attempt: Int, nextAttemptAt: Long)
suspend fun markFailed(id: Long)
suspend fun failed(): List<OutboxEntry>
suspend fun retry(id: Long)
}
class Backoff(private val baseMillis: Long, private val capMillis: Long) {
fun delayMillis(attempt: Int, random: Double): Long {
val capped = minOf(baseMillis shl minOf(attempt, 16), capMillis)
return (capped * (0.5 + random / 2)).toLong()
}
}
The outbox is unordered, so two writes to the same record have to be commutative, or the query has to hold back a row whose record already has an older one waiting. The key each entry carries is generated at the tap, which is shown in full in the checkout answer, and the read half of this architecture is the offline first answer.
How I'd build this on Android
I'd let the worker call a repository or sync engine. It does not call a ViewModel. WorkManager input contains an account or job ID, while Room holds the actual change and retry details. The ViewModel observes pending and failed changes through the repository and turns them into screen state.
@Dao
interface PendingMutationDao {
@Query("SELECT * FROM mutations WHERE status = 'FAILED' ORDER BY createdAt")
fun observeFailures(): Flow<List<MutationEntity>>
// Lease acquisition must be conditional and checked by the caller.
@Query("""
UPDATE mutations SET status = 'SYNCING', leaseUntil = :until
WHERE id = :id AND (status = 'PENDING' OR
(status = 'SYNCING' AND leaseUntil < :now))
""")
suspend fun claim(id: String, now: Long, until: Long): Int
@Query("DELETE FROM mutations WHERE id = :id")
suspend fun acknowledge(id: String)
}
The full MutationEntity stores the operation ID, account, record ID, creation order, payload and retry details. The payload stays unchanged once sent. claim gives a row to only one worker. If that worker dies, the row must become available again. One option is a lease, a temporary claim that expires. A permanent SYNCING flag can leave the row stuck forever. Lease timing must account for reboot, and the server must still recognise duplicate operation IDs if an old worker sends late.
Unique work prevents duplicate scheduled workers only when every caller uses the same name and policy. A separate foreground sync needs to use the same coordinator too. Changes to one record stay in order. A retry keeps the same ID and payload. If the user changes the content, that becomes a new operation.
The route collects a pending count and details of changes that need attention. I'd handle failures according to their cause.
- Cancellation stops this run without labelling the change as failed.
- Authentication failure waits for a refreshed session or sign in.
- A conflict waits for a merge or user decision.
- Invalid data stays visible so the user can fix it.
- A temporary failure retries after an increasing delay with some randomness.
A network constraint suggests there may be a connection. It does not prove the backend is reachable. I'd inject the API, DAO, clock and randomness into the engine. A WorkerFactory or Hilt worker factory creates the Android worker. The Notes example shows this setup.
I'd check these cases.
- A worker dying after claiming.
- A lost acknowledgement.
- An expired lease.
- An edit during upload.
- A crash before saving the next download cursor.
I'd watch the age of the oldest pending change. Many successful uploads can otherwise hide one that has been stuck for days.
Tradeoffs I'd call out
- Optimistic local writes vs waiting for confirmation. Showing a pending write as done immediately keeps the app responsive on a bad connection. It also means the UI can show something that later fails to sync, and that has to be handled explicitly. A silently reverted action is confusing, a clearly surfaced "failed to sync, retry?" is honest.
- Aggressive retry vs battery cost. Retrying quickly gets data synced sooner once the connection recovers. On a genuinely bad connection it burns battery and radio time against a network that isn't ready. Backoff with a sensible cap is the balance, and
WorkManager's constraints already stop the worker from running at all when there is no network. - Per request idempotency keys vs simpler fire and hope retries. Idempotency keys are the correct way to make retries safe, but they require backend support, because the server has to track and deduplicate by that key. Without it, retries risk duplicate writes. That is a real product bug, a submitted twice form or a duplicated message, not just a technical inconvenience.
What breaks at scale and offline
At scale, an outbox with no cap or expiry is the risk. A device offline for days accumulates a backlog that floods the network the moment it reconnects, so the worker batches and rate limits its own flush rather than firing every queued row at once. Fully offline, the outbox just grows and nothing is lost, because WorkManager's network constraint means the worker simply doesn't run until connectivity exists. That is the correct behavior and it needs no special casing.
Read more Task scheduling (opens in a new tab)
Watch