Most of the apps I build are used in places where the connection is not reliable. That changes the architecture more than any other requirement: the phone has to be the source of truth, and the server becomes a place to synchronise with, not to ask.
Local data first
Every screen reads from the local database and only from it. The network layer never renders UI directly; it writes into the same tables the UI observes. This sounds obvious until you meet a screen that “just needs one live value” — that one screen is where offline support usually breaks.
A small rule that helps: the repository exposes Flow objects, never suspend functions
that fetch. Fetching is the sync worker’s job.
Sync as a background job
Sync runs in a CoroutineWorker with a network constraint. Pushing before pulling
avoids overwriting a local edit with stale server data.
class SyncWorker(ctx: Context, params: WorkerParameters)
: CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
// Push local changes first, then pull.
return try { repo.push(); repo.pull(); Result.success() }
catch (e: IOException) { Result.retry() }
}
}
Handling conflicts
- Last-write-wins for simple fields, with a timestamp from the device.
- Merge for lists, so two people adding items do not erase each other.
- A visible “needs review” state when neither rule applies.
The goal is not to make conflicts impossible — it is to make them visible and cheap to fix.
Testing on bad networks
Airplane mode is not a test. Throttled, flapping and high-latency connections each break something different; the emulator’s network profiles cover the first two and a proxy covers the third.
What I would do differently
Start with the conflict rules. They shape the data model, and adding them later means migrating every table.