Droid Manual // Android Knowledge Base

Droid Manual

Offline First Architecture

November 02, 2026 · 9 min read

Introduction

Building an app that works flawlessly without an internet connection is no longer optional—users expect instant responsiveness even on spotty networks. Offline-first architecture flips the traditional model on its head: instead of treating the network as the primary data source and falling back to local storage, you treat the local database as the definitive source of truth and synchronize with remote servers whenever connectivity allows. This tutorial walks through building an offline-first Android app using Room, WorkManager, and a reactive UI that always reflects sync progress, from local writes through background uploads to conflict resolution.

What Is Offline-First Architecture

At its core, offline-first means every user action reads from and writes to the local database immediately. The network layer becomes secondary, invoked asynchronously by a background scheduler rather than on the critical UI path. This design yields three tangible benefits: the app responds instantly because no round-trip latency blocks input, data survives network outages without being lost, and users can continue working in airplane mode without any visible degradation. The trade-off is that the app must later reconcile divergent changes made locally while offline with any remote updates received during that window, which introduces the conflict-resolution challenge discussed later.

Local Database as the Source of Truth

The foundation of the pattern is a Room database that models your domain entities and exposes them through Data Access Objects. The database schema should be the single contract that the rest of the app adheres to; every repository method reads from or writes to Room, never directly to a Retrofit client. This isolation guarantees that the ViewModel and UI can observe a single Flow of data without needing to know whether a particular item has been synced to the server yet.

import android.content.Context
import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Entity(tableName = "todo_items")
data class TodoItem(
    @PrimaryKey val id: String,
    val title: String,
    val completed: Boolean = false,
    val serverSynced: Boolean = false,
    val updatedAt: Long = System.currentTimeMillis()
)

@Dao
interface TodoDao {
    @Query("SELECT * FROM todo_items ORDER BY updatedAt DESC")
    fun observeAll(): Flow<List<TodoItem>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(item: TodoItem)

    @Query("UPDATE todo_items SET completed = :completed, updatedAt = :timestamp WHERE id = :id")
    suspend fun updateCompleted(id: String, completed: Boolean, timestamp: Long)

    @Query("DELETE FROM todo_items WHERE id = :id")
    suspend fun deleteById(id: String)
}

@Database(entities = [TodoItem::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
    abstract fun todoDao(): TodoDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "offline_first_db"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

This class defines a TodoItem entity whose serverSynced flag is the primary signal the sync pipeline uses to decide which rows need to be uploaded. The REPLACE conflict strategy on insert means that if the same id already exists, the new values overwrite the old ones, which conveniently doubles as an upsert mechanism for when remote server updates are pulled down during a sync pass. The singleton companion object with double-checked locking keeps the database instance scoped to the application lifecycle, which is critical because opening and closing Room databases is an expensive operation.

Background Sync with WorkManager

The local write is instantaneous, but the app cannot leave data stranded in the database forever. A Worker subclass schedules an upload pass over all unsynced rows using a ConstraintTracker so the job only runs when the device has connectivity and, ideally, when charging. The worker calls the repository, which in turn performs a network request for each pending item and then marks them as synced inside a transaction. If the remote call fails for one item, the worker retries that item on the next scheduled run rather than marking it done.

import android.content.Context
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit

class SyncWorkManager(private val context: Context) {

    fun schedulePendingSync() {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresCharging(true)
            .build()

        val request = OneTimeWorkRequestBuilder<SyncWorker>()
            .setConstraints(constraints)
            .setInitialDelay(5, TimeUnit.SECONDS)
            .addTag("todo_sync")
            .build()

        WorkManager.getInstance(context).enqueueUniqueWork(
            "pending_todo_sync",
            ExistingWorkPolicy.KEEP,
            request
        )
    }

    fun cancelPendingSync() {
        WorkManager.getInstance(context).cancelUniqueWork("pending_todo_sync")
    }
}

class SyncWorker(
    appContext: Context,
    workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {

    private val db = AppDatabase.getInstance(appContext)
    private val api = FakeApiService()

    override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
        val pending = db.todoDao().getPendingItems()
        if (pending.isEmpty()) return@withContext Result.success()

        var lastException: Exception? = null
        for (item in pending) {
            try {
                api.uploadItem(item)
                db.todoDao().markSynced(item.id, System.currentTimeMillis())
            } catch (e: Exception) {
                lastException = e
                break
            }
        }

        if (lastException != null) {
            Result.retry()
        } else {
            Result.success()
        }
    }
}

Notice how ExistingWorkPolicy.KEEP is used when scheduling: if a sync worker is already running or queued from a previous write, the new request is dropped rather than chaining workers, which prevents duplicate upload passes from piling up in the queue. The retry logic here is deliberately coarse—it stops after the first failure so that partially synced batches do not silently succeed and leave orphaned unsynced rows, ensuring the next scheduled run can retry from where it left off.

Conflict Resolution Strategies

Conflicts arise when two devices edit the same todo item while offline, or when a user modifies a row locally after the last sync but before a remote update arrives. A pragmatic resolution strategy for most todo-style apps is a last-write-wins policy based on the updatedAt timestamp: whichever side has the newer timestamp wins the merge, and the loser's changes are discarded. More complex domains may need Operational Transformation or CRDTs, but those add significant implementation overhead and are rarely necessary for consumer Android apps.

The repository method below demonstrates a server pull that merges incoming updates using this rule, and it keeps a short conflict log that the UI can surface to the user as a non-blocking snackbar.

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

class TodoRepository(private val db: AppDatabase, private val api: FakeApiService) {

    val allItems: Flow<List<TodoItem>> = db.todoDao().observeAll()

    suspend fun pullRemoteUpdates(): List<String> {
        val remoteItems = api.fetchRemoteItems()
        val conflicts = mutableListOf<String>()

        for (remote in remoteItems) {
            val local = db.todoDao().getById(remote.id)
            if (local == null) {
                db.todoDao().insert(remote)
            } else if (remote.updatedAt > local.updatedAt) {
                if (remote.updatedAt != local.updatedAt &&
                    remote.completed != local.completed &&
                    remote.title != local.title
                ) {
                    conflicts.add("Item '${remote.id}' had conflicting edits; keeping remote version.")
                }
                db.todoDao().insert(remote)
            }
        }
        return conflicts
    }
}

The conditional check inside the loop detects true conflicts—situations where both sides changed independent fields—by comparing both the completed flag and the title. If only one field changed, the logic still resolves cleanly without entering the conflict branch, so the user sees a single quiet update rather than a spurious warning message. The returned list of conflict strings is typically collected in the ViewModel and exposed as a UI state object.

ViewModel and State Management

A well-structured ViewModel owns the repository and exposes StateFlow values that the UI observes. It must coordinate three independent concerns: the live list of items, the current sync status, and any conflict messages that surfaced during the last sync pass. Using StateFlow instead of MutableLiveData gives compile-time guaranteed non-null emission and makes the state object immutable to the outside world, which simplifies testing and prevents accidental mutation inside fragments or activities.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

data class TodoUiState(
    val items: List<TodoItem> = emptyList(),
    val isSyncing: Boolean = false,
    val conflicts: List<String> = emptyList(),
    val lastSyncTime: Long? = null
)

class TodoViewModel(
    private val repository: TodoRepository,
    private val syncManager: SyncWorkManager
) : ViewModel() {

    private val _uiState = MutableStateFlow(TodoUiState())
    val uiState: StateFlow<TodoUiState> = _uiState

    init {
        viewModelScope.launch {
            repository.allItems
                .map { items ->
                    val current = _uiState.value
                    _uiState.value = current.copy(items = items)
                }
                .collect {}
        }
    }

    fun addTodo(title: String) {
        viewModelScope.launch {
            val id = java.util.UUID.randomUUID().toString()
            repository.insert(TodoItem(id = id, title = title))
            syncManager.schedulePendingSync()
        }
    }

    fun toggleCompleted(id: String, completed: Boolean) {
        viewModelScope.launch {
            repository.updateCompleted(id, completed)
            syncManager.schedulePendingSync()
        }
    }

    fun performSync() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isSyncing = true)
            val conflicts = repository.pullRemoteUpdates()
            _uiState.value = _uiState.value.copy(
                isSyncing = false,
                conflicts = conflicts,
                lastSyncTime = System.currentTimeMillis()
            )
            syncManager.schedulePendingSync()
        }
    }

    fun dismissConflicts() {
        _uiState.value = _uiState.value.copy(conflicts = emptyList())
    }
}

Calling schedulePendingSync() after every mutation guarantees that the WorkManager queue is notified even if the previous sync worker is still in progress, because the existing-scheduled-worker policy will simply be skipped and the already-queued job will handle the new unsynced row on its next run. The performSync method explicitly flips isSyncing to true before launching the coroutine work and flips it back afterward, giving the UI a precise animation boundary rather than relying on implicit state transitions.

Showing Sync State in the UI

The UI layer should be a pure function of the TodoUiState: it reads the state, renders the list, and surfaces sync progress through a small visual indicator rather than blocking the main thread. A ProgressBar or circular indeterminate animation inside the RecyclerView header, combined with a color-coded sync badge on each card, gives the user immediate feedback without distracting from the primary task. The adapter pattern keeps layout inflation efficient, and observing the StateFlow with repeatOnLifecycle ensures the collector is paused when the screen is not visible.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">

    <ProgressBar
        android:id="@+id/syncProgress"
        style="?android:attr/progressBarStyleSmall"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="end"
        android:indeterminate="true"
        android:visibility="gone" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/todoList"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:paddingTop="8dp"
        android:paddingBottom="8dp" />

    <TextView
        android:id="@+id/conflictMessage"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="12dp"
        android:textColor="#B32424"
        android:background="#FFF3E0"
        android:visibility="gone" />

</LinearLayout>

Each row in the actual adapter would bind the serverSynced boolean to an icon tint—gray when unsynced and green when confirmed—so the user can visually scan which items might still be pending upload. The conflict TextView above the list is intentionally kept separate from the list so it can animate into view with a simple setVisibility call and does not cause an unwanted scrollbar recalculation.

Common Pitfalls

The most frequent mistake teams make is calling the network on the main thread from inside the ViewModel, which defeats the entire purpose of local-first design and produces ANR crashes under poor connectivity. Another pitfall is scheduling a new sync worker after every single mutation without checking whether one is already queued; this floods WorkManager and wastes battery. Always deduplicate by keeping a reference to the last scheduled WorkRequest ID and coalescing rapid mutations using a debounce timer or by simply relying on the KEEP policy to skip redundant requests. A third subtle bug is using Int primary keys with autoGenerate = true in Room while simultaneously expecting remote IDs to map cleanly; switch to String UUIDs from the start to avoid an awkward migration later. Finally, never treat the serverSynced flag as a guarantee of permanence—it is merely a snapshot at sync time, and transient server errors can leave items appearing synced when they are actually unacknowledged.

Conclusion

Offline-first architecture trades a modest increase in local storage complexity for a dramatically better perceived performance and reliability. By anchoring every user interaction to the Room database, delegating network work to WorkManager, resolving conflicts with a clear timestamp-based policy, and surfacing sync state through reactive flows, you build an app that feels instant on any connection speed. The patterns shown here—singleton database initialization, idempotent upsert logic, and a ViewModel that coordinates sync visibility—scale well beyond a simple todo list and form the backbone of production-grade offline-first Android applications.