Android System Design Interview Questions
In an Android app, how do you handle data syncing when the network isn't stable?
Tier: CommonDifficulty: Medium
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 mid-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();
}
}
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));
}
}
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);
}
}
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()
}
}
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()
}
}
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.
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 double-submitted 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