SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:23:14
UP00:01
MOD · ARTICLE · DATA LAYERS/N · AX-OFFLINPUBLISHED
All Articles
Data Layer · Updated 12 MIN

Offline-First Android Architecture with Room and WorkManager

Build Android apps that work without a network. Local-first data patterns, sync strategies, conflict resolution, and background sync with WorkManager.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why Offline-First Is Not Optional

Mobile networks fail in lifts, tunnels and crowded airports. An app that shows a spinner every time is broken for part of every day it is used. Android's architecture guide gives the working definition: "An offline-first app is an app that is able to perform all, or a critical subset of its core functionality without access to the internet" (Android). The rule that makes it possible is about where the screen gets its data: "The local data source is the canonical source of truth for the app. It should be the exclusive source of any data that higher layers of the app read." The network becomes a synchronization channel. The user adds a task, the task is in the database, the screen shows it. Whether the server has heard about it yet is a separate question with a separate answer. This article was checked against that guide in October 2026. The earlier samples had two defects in the sync path that would have lost writes in production, and the conflict section put a decision on the device that the guide puts on the server. All three are corrected below.

The Repository Pattern: Local-First Reads

The repository sits between the two data sources, and the guide is firm about its shape: "A repository with network access in an offline-first app must always have a local data source." Reads come from Room as a Flow. A refresh fetches from the network and writes to Room, and the screen updates because the query it observes changed. The network never hands data to the UI directly. Catch the failures you expect, IOException and HttpException. The earlier sample caught Exception, which also swallows coroutine cancellation and reports a cancelled refresh as a failed one. The rule and the reason are in the coroutines deep dive. A long list read this way should be paged, with the database still in the middle. That is what RemoteMediator is for, covered in the Paging 3 guide.
kotlin
class ArticleRepository @Inject constructor(
    private val articleDao: ArticleDao,
    private val api: ArticleApi,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
    // The UI observes Room and nothing else
    fun observeArticles(): Flow<List<Article>> =
        articleDao.observeAll()

    // Refresh: fetch from the network, write to Room
    suspend fun refreshArticles(): Result<Unit> =
        withContext(ioDispatcher) {
            try {
                val remote = api.getArticles()
                articleDao.upsertAll(remote.map { it.toEntity() })
                Result.success(Unit)
            } catch (e: IOException) {
                Result.failure(e)
            } catch (e: HttpException) {
                Result.failure(e)
            }
        }
}

@HiltViewModel
class ArticleListViewModel @Inject constructor(
    private val repo: ArticleRepository
) : ViewModel() {

    val articles = repo.observeArticles()
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5_000),
            emptyList()
        )

    val isRefreshing = MutableStateFlow(false)

    init { refresh() }

    fun refresh() {
        viewModelScope.launch {
            isRefreshing.value = true
            try {
                repo.refreshArticles()
            } finally {
                isRefreshing.value = false
            }
        }
    }
}

How should an offline-first app handle writes?

Reads are the easy half. For writes the guide says "there are three strategies to consider", and choosing the wrong one is a product decision made by accident:
StrategyWhat it doesUse it for
Online-only writesWrites across the network first and updates local data only on successTransactions that must happen online, such as a bank transfer
Queued writesPuts the object in a queue and drains it when the app is back onlineAnalytics events and logging
Lazy writesWrites locally first, then queues the write for the networkData that is critical to the app
The earlier version of this article called its pattern "queue and sync". In the guide's vocabulary it is a lazy write: "Write to the local data source first, then queue the write to notify the network at the earliest opportunity." And the guide is clear on when: "This approach is the correct choice when the data is critical to the app." The simplest implementation is a status column on the entity. A new or edited row is PENDING. A sync job pushes pending rows and marks them SYNCED. The earlier sync function had the two defects mentioned above. It caught every exception and marked the row FAILED, and its query only selected PENDING rows, so a write that failed once because the train entered a tunnel was never sent again. It also returned success no matter what happened, so the worker's retry branch could not run. The version below separates the two kinds of failure. A network error is rethrown, the row stays PENDING, and WorkManager retries. A request the server rejects is marked FAILED, because sending it again will not change the answer.
kotlin
enum class SyncStatus { SYNCED, PENDING, FAILED }

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey
    val id: String = UUID.randomUUID().toString(),
    val title: String,
    val description: String,
    val isCompleted: Boolean = false,
    val syncStatus: SyncStatus = SyncStatus.PENDING,
    val lastModified: Long = System.currentTimeMillis(),
)

@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY lastModified DESC")
    fun observeAll(): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks WHERE syncStatus = 'PENDING'")
    suspend fun getPendingSync(): List<TaskEntity>

    @Upsert
    suspend fun upsert(task: TaskEntity)
}

class TaskRepository @Inject constructor(
    private val dao: TaskDao,
    private val api: TaskApi,
    private val syncScheduler: SyncScheduler,
) {
    // Lazy write: local first, then queue the network write
    suspend fun createTask(title: String, desc: String) {
        dao.upsert(
            TaskEntity(
                title = title,
                description = desc,
                syncStatus = SyncStatus.PENDING,
            )
        )
        syncScheduler.requestSync()
    }

    // Called by the sync worker.
    // Throws IOException so the worker can retry; the row stays PENDING.
    suspend fun syncPendingTasks() {
        for (task in dao.getPendingSync()) {
            try {
                api.upsertTask(task.toApiModel())
                dao.upsert(task.copy(syncStatus = SyncStatus.SYNCED))
            } catch (e: HttpException) {
                if (e.code() in 400..499) {
                    // Rejected by the server: retrying will not help
                    dao.upsert(task.copy(syncStatus = SyncStatus.FAILED))
                } else {
                    throw IOException("Server error ${e.code()}", e)
                }
            }
        }
    }
}

Background Sync with WorkManager

Draining the queue is work that must outlive the screen, and the guide names the tool: on Android this "is persistent work that is often delegated to WorkManager". Constraints, backoff and unique work are covered in WorkManager mastery; three decisions matter here. Retry on the right errors, and stop. The guide's criteria for backing off include "The kind of error that the network data source indicated" and "Maximum allowable retries". Its example draws the same line as the repository above: retry calls that fail for lack of connectivity, "Don't retry HTTP requests that are not authorized until proper credentials are available." Pick the unique-work policy on purpose. The earlier sample enqueued the sync after each write with ExistingWorkPolicy.REPLACE. The reference says what that does to work already in progress: "cancel and delete it" (Android). Every new write cancelled the upload of the previous ones. KEEP has the opposite flaw: a write that lands while a sync is running is ignored until the next periodic run. APPEND_OR_REPLACE queues the new request behind the running one, so nothing is cancelled and nothing is skipped. Keep one startup or periodic sync. For that the guide's own sample, from the Now in Android app, enqueues unique work with KEEP, so that only one sync worker runs at a time.
kotlin
@HiltWorker
class SyncWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val taskRepo: TaskRepository,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            taskRepo.syncPendingTasks()
            Result.success()
        } catch (e: IOException) {
            // Connectivity or server trouble: back off and try again
            if (runAttemptCount < MAX_ATTEMPTS) Result.retry()
            else Result.failure()
        }
    }

    private companion object {
        const val MAX_ATTEMPTS = 5
    }
}

class SyncScheduler @Inject constructor(
    @ApplicationContext private val context: Context
) {
    private val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()

    fun schedulePeriodicSync() {
        val periodicSync =
            PeriodicWorkRequestBuilder<SyncWorker>(
                30, TimeUnit.MINUTES,
                5, TimeUnit.MINUTES  // flex interval
            )
            .setConstraints(constraints)
            .setBackoffCriteria(
                BackoffPolicy.EXPONENTIAL,
                1, TimeUnit.MINUTES
            )
            .build()

        WorkManager.getInstance(context)
            .enqueueUniquePeriodicWork(
                "periodic-sync",
                ExistingPeriodicWorkPolicy.KEEP,
                periodicSync,
            )
    }

    // After a local write: runs when connected, behind any sync in flight
    fun requestSync() {
        val oneTimeSync =
            OneTimeWorkRequestBuilder<SyncWorker>()
                .setConstraints(constraints)
                .build()

        WorkManager.getInstance(context)
            .enqueueUniqueWork(
                "write-sync",
                ExistingWorkPolicy.APPEND_OR_REPLACE,
                oneTimeSync,
            )
    }
}

Conflict Resolution Strategies

Two devices edit the same record offline, then both sync. The guide's answer for most mobile apps is last write wins, and it says who decides: "devices attach timestamp metadata to the data they write to the network. When the network data source receives them, it discards any data older than its current state while accepting those newer than its current state." That places the decision at the network data source. The earlier sample compared timestamps on the device and kept the local copy when it looked newer. Two devices doing that can each conclude they won. The device's job is to send the timestamp and accept the verdict: after a sync, what the server returns replaces the local row. Last write wins is simple, and it discards the losing edit whole. Where that is too blunt, a field-level merge keeps both edits when they touched different fields. It needs the last synced version of the record as a base to compare against. This is our own pattern, not the guide's, and it belongs on the server for the same reason. For data where neither side may be discarded silently, show the user both versions and let them choose.
kotlin
// The device sends its timestamp and applies what comes back
suspend fun pushTask(local: TaskEntity) {
    // The server compares lastModified with its own state and
    // returns the version that won.
    val winner: TaskApiModel = api.upsertTask(local.toApiModel())

    dao.upsert(
        winner.toEntity().copy(syncStatus = SyncStatus.SYNCED)
    )
}

// Field-level merge, as server-side logic
fun fieldLevelMerge(
    base: TaskEntity,   // Last version both sides agreed on
    local: TaskEntity,  // Edit from device A
    remote: TaskEntity, // Edit from device B
): TaskEntity {
    val mergedTitle = when {
        local.title == base.title -> remote.title   // Only B changed it
        remote.title == base.title -> local.title   // Only A changed it
        else -> remote.title  // Both changed it: later write wins
    }

    val mergedCompleted = when {
        local.isCompleted == base.isCompleted -> remote.isCompleted
        remote.isCompleted == base.isCompleted -> local.isCompleted
        else -> remote.isCompleted
    }

    return base.copy(
        title = mergedTitle,
        isCompleted = mergedCompleted,
    )
}

Showing Sync Status in the UI

People should be able to tell that their work is safe. A small indicator on rows that have not synced yet is enough. Most users do not want the mechanics, so show nothing when everything is synced. Make the failed state louder than the pending one. Pending means the app will handle it. Failed, in the scheme above, means the server rejected the write and no retry will fix it, so the user has to act.
kotlin
@Composable
fun TaskItem(
    task: TaskEntity,
    onToggleComplete: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Checkbox(
            checked = task.isCompleted,
            onCheckedChange = { onToggleComplete() },
        )

        Column(modifier = Modifier.weight(1f)) {
            Text(task.title,
                style = MaterialTheme.typography.bodyLarge)
            Text(task.description,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme
                    .onSurfaceVariant)
        }

        when (task.syncStatus) {
            SyncStatus.PENDING -> Icon(
                Icons.Outlined.CloudUpload,
                contentDescription = "Waiting to sync",
                tint = MaterialTheme.colorScheme.outline,
                modifier = Modifier.size(16.dp),
            )
            SyncStatus.FAILED -> Icon(
                Icons.Outlined.CloudOff,
                contentDescription = "Sync failed, needs attention",
                tint = MaterialTheme.colorScheme.error,
                modifier = Modifier.size(16.dp),
            )
            SyncStatus.SYNCED -> { /* No indicator */ }
        }
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1The local data source is the source of truth. The screen reads Room and nothing else.
  2. 2Android documents three write strategies. Writing locally first and queueing the network write is a lazy write, the right choice for critical data.
  3. 3A network error must leave the row pending and let the worker retry. Only a rejected request is a permanent failure.
  4. 4ExistingWorkPolicy.REPLACE cancels a sync already in flight. Use APPEND_OR_REPLACE for a sync requested after each write.
  5. 5With last write wins, the device attaches the timestamp and the network data source decides.
  6. 6Show pending quietly and failed loudly, and show nothing when everything is synced.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

How do I handle writes made while offline?

Write to the local database first and mark the row as pending, then queue a WorkManager job that needs a network connection. When it runs, push the pending rows and mark them synced. Leave a row pending after a network error so it is retried, and mark it failed only when the server rejects it.

When should an app be offline-first?

When it has core functions people need without a connection. Android defines an offline-first app as one that can perform all, or a critical subset, of its core functionality without internet access. A transaction that must happen online, such as a bank transfer, stays an online-only write even inside an offline-first app.

Who should resolve a sync conflict, the device or the server?

The server. In the last write wins approach Android describes, devices attach a timestamp to what they write and the network data source discards anything older than its current state. A device that compares timestamps locally can decide it won while another device decides the same thing about its own copy.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA