On this page
A field agent fills in an inspection form in a basement with no signal. A traveller checks their itinerary on airplane mode. A shopper on a crowded network taps "add to cart". In an online-only app, all three see spinners or errors. In an offline-first app, they don't notice the network at all.
Core principles
- The local database is the single source of truth. The UI reads only from it.
- Writes go local first, then sync to the server in the background.
- Sync is a separate, resilient process (WorkManager), not part of a screen.
- Conflicts have an explicit policy.
Tracking local changes
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey val id: String, // client-generated UUID: works offline
val title: String,
val done: Boolean,
val updatedAt: Long, // local modification time
val serverVersion: Long?, // version from server, for conflict detection
val pendingSync: Boolean = false,
val deleted: Boolean = false, // soft delete until the server confirms
)Client-generated IDs mean new items can be created and referenced offline, without waiting for a server ID.
The write path
class TaskRepository(
private val dao: TaskDao,
private val syncScheduler: SyncScheduler,
private val clock: Clock,
) {
fun observeTasks(): Flow<List<Task>> =
dao.observeVisible().map { list -> list.map(TaskEntity::toDomain) } // WHERE deleted = 0
suspend fun toggleDone(id: String) {
dao.update(id) { it.copy(done = !it.done, updatedAt = clock.millis(), pendingSync = true) }
syncScheduler.requestSync() // enqueue unique work; runs when online
}
suspend fun delete(id: String) {
dao.update(id) { it.copy(deleted = true, pendingSync = true, updatedAt = clock.millis()) }
syncScheduler.requestSync()
}
}The sync worker
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val dao: TaskDao,
private val api: TaskApi,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
// 1. Push local changes
dao.pending().forEach { local ->
if (local.deleted) api.delete(local.id)
else {
val saved = api.upsert(local.toDto()) // server resolves version conflicts
dao.markSynced(local.id, saved.version)
}
if (local.deleted) dao.hardDelete(local.id)
}
// 2. Pull remote changes since last sync
val changes = api.changesSince(dao.maxServerVersion() ?: 0)
dao.applyRemote(changes.map { it.toEntity() }) // skip rows with pendingSync = true
Result.success()
} catch (e: IOException) {
Result.retry() // backoff handled by WorkManager
}
}
class SyncScheduler(private val workManager: WorkManager) {
fun requestSync() = workManager.enqueueUniqueWork(
"sync", ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build(),
)
}With ExistingWorkPolicy.KEEP, a change made while a sync is already running waits for the next sync. APPEND_OR_REPLACE queues a follow-up run instead. Pick the policy that fits your sync frequency.
Conflict resolution strategies
| Strategy | How | Good for |
|---|---|---|
| Last write wins | Newest timestamp or version wins | Simple personal data, low conflict risk |
| Server wins | Local change discarded on conflict; user notified | Shared, authoritative data (inventory) |
| Field-level merge | Merge non-overlapping field changes | Forms and records edited on several devices |
| User resolves | Show both versions | Documents where losing edits is unacceptable |
Offline UX
- Show a subtle "Offline: changes will sync" banner, not an error.
- Mark unsynced items (a small clock icon), and show failures that need action.
- Disable only the features that truly need the network, such as payments, and explain why.
- Show "last updated" times for data that may be stale.
Where offline-first matters most
Field work
Inspections, deliveries and surveys in areas with poor coverage.
Productivity
Notes, tasks and to-do apps, where users expect instant saves.
Content
News or courses downloaded for later, with progress synced when online.
Frequently asked questions
Does every app need to be offline-first?
No. Real-time features such as payments, live bidding and video calls are inherently online. But caching reads and preserving user input help almost every app.
How do I handle deleting items that were never synced?
If an item was created locally and deleted before syncing, remove it locally and skip the server call entirely.
Can I use Firestore's offline persistence instead?
Yes. Firestore provides built-in offline caching and sync. It trades control and portability for convenience.
Conclusion
Offline-first architecture makes the local database the source of truth, writes locally with pending-sync metadata, syncs through a WorkManager worker with backoff, and resolves conflicts with an explicit policy. The result is an app that feels instant and never loses user work.
Next step: handle large lists efficiently with Pagination with Paging 3.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Networking, persistence and offline sync are where real apps get complicated. Practise building a data layer that handles slow networks, failures and caching, not just the happy path.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.