Building Offline-First Android Apps with Room, Kotlin Flow, and WorkManager
DEV Community

Building Offline-First Android Apps with Room, Kotlin Flow, and WorkManager

Modern mobile users have zero tolerance for loading spinners when network connectivity drops in transit or elevators. In traditional "network-first" apps, the UI waits for an HTTP response before updating screen state. If the connection fails, the user sees an error dialog.

In an Offline-First Architecture, the paradigm is inverted:

  • The local database (Room) is the Single Source of Truth.
  • The UI observes database tables reactively via Kotlin Flow.
  • All user actions mutate the local database immediately for instant UI feedback.
  • Background sync jobs (via WorkManager) synchronize local modifications with the remote backend when the network is available.

Offline-First Data Flow

[User Action]
      |
      v
[Repository]
      |--> 1. Write immediately to Local Room DB (Instant UI Update via Flow!)
      +--> 2. Enqueue Background Sync Task (WorkManager)
      |
      v (When Network Connected)
[Remote Backend API]
      |
      v
[Fetch Delta & Update Room]

The user never experiences network latency or blocked interactions.

1. The Entity and Room DAO

Every entity includes synchronization metadata: a syncStatus flag (SYNCED, PENDING).

package com.devto.offlinefirst.data

import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow

enum class SyncStatus { SYNCED, PENDING }

@Entity(tableName = "notes")
data class NoteEntity(
    @PrimaryKey val id: String,
    val title: String,
    val content: String,
    val updatedAt: Long,
    val syncStatus: SyncStatus = SyncStatus.PENDING
)

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY updatedAt DESC")
    fun observeAllNotes(): Flow<List<NoteEntity>>

    @Query("SELECT * FROM notes WHERE syncStatus = 'PENDING'")
    suspend fun getPendingNotes(): List<NoteEntity>

    @Upsert
    suspend fun upsertNote(note: NoteEntity)

    @Query("UPDATE notes SET syncStatus = 'SYNCED' WHERE id = :noteId")
    suspend fun markSynced(noteId: String)
}

2. The Repository: Decoupling UI from Network

package com.devto.offlinefirst.data

import androidx.work.*
import kotlinx.coroutines.flow.Flow
import java.util.UUID

class NoteRepository(
    private val noteDao: NoteDao,
    private val workManager: WorkManager
) {
    val notesStream: Flow<List<NoteEntity>> = noteDao.observeAllNotes()

    suspend fun createNote(title: String, content: String) {
        val newNote = NoteEntity(
            id = UUID.randomUUID().toString(),
            title = title,
            content = content,
            updatedAt = System.currentTimeMillis(),
            syncStatus = SyncStatus.PENDING
        )
        // 1. Instant local write - screen updates immediately!
        noteDao.upsertNote(newNote)
        // 2. Schedule background sync with network constraint
        scheduleSyncWork()
    }

    private fun scheduleSyncWork() {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

        val syncRequest = OneTimeWorkRequestBuilder<SyncNotesWorker>()
            .setConstraints(constraints)
            .setBackoffCriteria(
                BackoffPolicy.EXPONENTIAL,
                15,
                java.util.concurrent.TimeUnit.SECONDS
            )
            .build()

        workManager.enqueueUniqueWork(
            "SyncNotesWork",
            ExistingWorkPolicy.APPEND_OR_REPLACE,
            syncRequest
        )
    }
}

Key Architectural Guidelines

  • Never perform network calls directly in the ViewModel: ViewModels should only observe domain streams and call repository mutation methods.
  • Handle Conflict Resolution: Choose a clear strategy: Last-Write-Wins (timestamp comparison) or server-side three-way merging.
  • Optimistic UI by Default: Assume operations will succeed locally. Never show a blocking dialog for standard data entry.
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.