Offline-First Android Architecture with Room and WorkManager

Build Android apps that work without a network connection. Covers local-first data patterns, sync strategies, and conflict resolution.

Introduction

Users expect apps to work regardless of network connectivity. Offline-first architecture treats the local database as the source of truth, with background sync keeping remote data current. This guide builds production offline-first apps with Room and WorkManager.

Local-First Data Patterns

Store all data in Room database. UI observes local data exclusively. Network operations update local database, not UI directly. This ensures instant UI response and offline availability.

Repository Sync Strategy

Repository fetches from network, saves to database. Room triggers UI updates via Flow. Use timestamp-based freshness checking. Implement cache invalidation policies.

Background Sync with WorkManager

Use WorkManager for reliable background sync. Chain sync operations for ordered execution. Set constraints for network type and battery. Handle retry with exponential backoff.

Conflict Resolution

Use last-write-wins for simple cases. Implement field-level merging for complex data. Let users resolve conflicts for critical data. Track modification timestamps for all entities.

Frequently Asked Questions

How do I handle offline writes?

Store writes in database with pending flag. Use WorkManager to sync pending writes when online. Handle conflicts server-side or with timestamp comparison.

When should I go offline-first?

Always, unless your app is purely a real-time dashboard. Users expect apps to work in elevators, subways, and airplanes. Offline-first improves perceived performance even on good networks.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:38:13
UP00:01
MOD · ARTICLE · DATA LAYERS/N · AX-OFFLINPUBLISHED
Data LayerFeb 20, 202612 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 are unreliable. Users ride elevators, enter tunnels, travel through rural areas, and connect to congested airport WiFi. An app that shows a spinner or error screen whenever the network is unavailable delivers a broken experience for a significant portion of usage time. Offline-first means the app always reads from and writes to a local database. The network is a background synchronization channel, not a prerequisite for functionality. When the user adds a task, edits a note, or marks an item as favorite, the change is persisted locally and visible instantly. Sync happens in the background when connectivity is available. This architecture is more complex than a simple network-first approach, but it produces apps that feel fast and reliable. Users don't notice sync happening -- they just see an app that works.

The Repository Pattern: Local-First Reads

The repository is the central coordinator between local storage and remote API. For reads, the pattern is: always return data from Room, and refresh from the network in the background. The UI observes Room via Flow, so it updates automatically when new data arrives from sync. This produces instant UI rendering (data is already local) with eventual consistency (network data arrives moments later).
kotlin
class ArticleRepository @Inject constructor(
    private val articleDao: ArticleDao,
    private val api: ArticleApi,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
    // UI observes this Flow -- always has data from Room
    fun observeArticles(): Flow<List<Article>> =
        articleDao.observeAll()

    // Background refresh: fetch from network, save 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: Exception) {
                Result.failure(e)
            }
        }
}

// ViewModel: observe local data, trigger refresh
@HiltViewModel
class ArticleListViewModel @Inject constructor(
    private val repo: ArticleRepository
) : ViewModel() {

    val articles = repo.observeArticles()
        .stateIn(
            viewModelScope,
            SharingStarted.Lazily,
            emptyList()
        )

    val isRefreshing = MutableStateFlow(false)

    init { refresh() }

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

Offline Writes: Queue and Sync

Offline writes are the hard part. When a user creates or modifies data without a network connection, you need to queue the change locally and sync it when connectivity returns. This requires tracking which local records have pending changes. For simple key-value flags like last-sync timestamps, DataStore is a lighter alternative to Room. The simplest approach: add a syncStatus column to your Room entities. New or modified records get status PENDING. A background sync job reads pending records, pushes them to the API, and updates the status to SYNCED on success.
kotlin
// Entity with sync tracking
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)
}

// Repository: write locally, queue for sync
class TaskRepository @Inject constructor(
    private val dao: TaskDao,
    private val api: TaskApi,
) {
    // Writes go to Room immediately -- instant UI update
    suspend fun createTask(title: String, desc: String) {
        val task = TaskEntity(
            title = title,
            description = desc,
            syncStatus = SyncStatus.PENDING,
        )
        dao.upsert(task)
        // Sync happens via WorkManager
    }

    // Called by WorkManager sync job
    suspend fun syncPendingTasks(): Result<Unit> {
        val pending = dao.getPendingSync()
        for (task in pending) {
            try {
                api.upsertTask(task.toApiModel())
                dao.upsert(
                    task.copy(syncStatus = SyncStatus.SYNCED)
                )
            } catch (e: Exception) {
                dao.upsert(
                    task.copy(syncStatus = SyncStatus.FAILED)
                )
            }
        }
        return Result.success(Unit)
    }
}

Background Sync with WorkManager

WorkManager is the right tool for background sync. It handles constraints (only sync when connected), retry policies (exponential backoff on failure), and survives app restarts and device reboots. Schedule a periodic sync worker and a one-time sync triggered by local writes.
kotlin
// Sync Worker
@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: Exception) {
            if (runAttemptCount < 3) Result.retry()
            else Result.failure()
        }
    }
}

// Schedule sync
object SyncScheduler {

    fun schedulePeriodicSync(context: Context) {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

        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,
            )
    }

    // Trigger immediate sync after a local write
    fun triggerImmediateSync(context: Context) {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

        val oneTimeSync =
            OneTimeWorkRequestBuilder<SyncWorker>()
                .setConstraints(constraints)
                .build()

        WorkManager.getInstance(context)
            .enqueueUniqueWork(
                "immediate-sync",
                ExistingWorkPolicy.REPLACE,
                oneTimeSync,
            )
    }
}

Conflict Resolution Strategies

When two devices modify the same record offline and then sync, you have a conflict. The right resolution strategy depends on your data: **Last-write-wins (LWW)**: Simplest strategy. The record with the latest timestamp overwrites the other. Works for most user-facing data where edits don't happen simultaneously. **Field-level merge**: Compare individual fields and keep the most recent change per field. More complex but preserves changes from both sides when they edited different fields. **Manual resolution**: Present the conflict to the user and let them choose. Reserved for high-value data where automatic resolution could lose important changes.
kotlin
// Last-write-wins with server arbitration
class ConflictResolver {

    fun resolveTask(
        local: TaskEntity,
        remote: TaskApiModel
    ): TaskEntity {
        return if (local.lastModified > remote.lastModified) {
            // Local is newer -- keep local, push to server
            local.copy(syncStatus = SyncStatus.PENDING)
        } else {
            // Remote is newer -- accept remote
            remote.toEntity().copy(
                syncStatus = SyncStatus.SYNCED
            )
        }
    }
}

// Field-level merge for richer conflict handling
fun fieldLevelMerge(
    base: TaskEntity,   // Last known synced version
    local: TaskEntity,  // Local changes
    remote: TaskEntity, // Remote changes
): TaskEntity {
    val mergedTitle = when {
        local.title == base.title -> remote.title
        remote.title == base.title -> local.title
        local.title == remote.title -> local.title
        else -> local.title // Both changed -- prefer local
    }

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

    return local.copy(
        title = mergedTitle,
        isCompleted = mergedCompleted,
        syncStatus = SyncStatus.PENDING,
    )
}

Showing Sync Status in the UI

Users should know when their data is synced and when changes are pending. A small indicator showing sync state builds trust and prevents confusion when data appears different across devices. Keep the indicator subtle -- most users don't care about sync mechanics. A small cloud icon with a checkmark (synced) or a spinner (pending) in the toolbar or on individual items is sufficient. Only surface errors prominently when sync has failed repeatedly and user action might be needed.
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)
        }

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

Key Takeaways

  1. 1Offline-first means always read from and write to Room -- the network is a sync channel, not a prerequisite.
  2. 2The repository pattern coordinates local storage and remote API with Room Flow for reactive UI.
  3. 3Track pending changes with a syncStatus column on your Room entities.
  4. 4Use WorkManager for background sync with network constraints and exponential backoff.
  5. 5Last-write-wins is the simplest conflict resolution; field-level merge preserves more data.
  6. 6Show subtle sync indicators so users know their data is safe without overwhelming them.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

How do I handle offline writes?

Store writes in database with pending flag. Use WorkManager to sync pending writes when online. Handle conflicts server-side or with timestamp comparison.

When should I go offline-first?

Always, unless your app is purely a real-time dashboard. Users expect apps to work in elevators, subways, and airplanes. Offline-first improves perceived performance even on good networks.

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