๐Ÿ”Œ APIs, Databases, and Backend Integration

Offline-First Android App Architecture

Offline-first means the app works fully from local data and syncs with the server when it can. Users get instant screens and no lost work on bad networks. Here's the architecture, the sync strategies and the UX details that make it work.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Advanced
On this page
  1. Core principles
  2. Tracking local changes
  3. The write path
  4. The sync worker
  5. Conflict resolution strategies
  6. Offline UX
  7. Where offline-first matters most
  8. Frequently asked questions
  9. Conclusion

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.
Offline-first read and write paths Read path: the UI observes Room through the repository. Write path: user edits are written to Room immediately with a pending sync flag and an outbox entry. A WorkManager sync worker, constrained to connected networks, pushes pending changes to the server and pulls remote changes into Room, which updates the UI automatically. UIobserves Repositorylocal-first writes Roomdata + outboxsource of truth SyncWorkerWorkManager Server Flow write User never waits for the network. Sync happens when constraints allow.
Two separate loops: the UI loop (instant, local) and the sync loop (eventual, resilient).

Tracking local changes

Entities with sync metadataKotlin
@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

TaskRepository.ktKotlin
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

SyncWorker.ktKotlin
@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

StrategyHowGood for
Last write winsNewest timestamp or version winsSimple personal data, low conflict risk
Server winsLocal change discarded on conflict; user notifiedShared, authoritative data (inventory)
Field-level mergeMerge non-overlapping field changesForms and records edited on several devices
User resolvesShow both versionsDocuments 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.

See all our apps โ†’

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.

Share: LinkedIn X / Twitter WhatsApp