DEV Community

Cover image for Building Offline-First Android Apps with Room, Kotlin Flow, and WorkManager
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

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

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:

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

The 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]
Enter fullscreen mode Exit fullscreen mode

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

@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)
}
Enter fullscreen mode Exit fullscreen mode

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
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Architectural Guidelines

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

Top comments (0)