Coding

Designing an Offline-First Android App: A System Design Case Study

Ahmad Najar Ahmad Najar 3 min read

Offline-First Isn't a Feature — It's an Architecture

"Add offline support" is one of the most commonly underestimated asks in Android development. Bolting a cache onto an app that assumes network-always breaks in every direction. Building offline-first from the ground up means every read and write goes through local storage first, full stop — the network becomes a background sync detail, not the primary data path.

The scenario: a task-management app

Let's design one concretely — think Todoist or a lightweight project tracker. Users create, edit, complete, and delete tasks, often on a train with no signal, and expect everything to reconcile once they're back online.

Step 1: Local database as the only thing the UI touches

@Entity
data class TaskEntity(
    @PrimaryKey val id: String,
    val title: String,
    val isDone: Boolean,
    val updatedAt: Long,
    val syncStatus: SyncStatus // SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE
)

@Dao
interface TaskDao {
    @Query("SELECT * FROM TaskEntity WHERE syncStatus != 'PENDING_DELETE' ORDER BY updatedAt DESC")
    fun observeTasks(): Flow>

    @Upsert
    suspend fun upsert(task: TaskEntity)
}

Every user action — creating a task, checking it off — is a local write with an updated syncStatus, applied instantly. The UI observes observeTasks() and reflects the change immediately, with zero dependency on network latency.

Step 2: A sync engine that reconciles in the background

class SyncWorker(
    context: Context,
    params: WorkerParameters,
    private val taskDao: TaskDao,
    private val api: TaskApi
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        // Push local changes first
        val pending = taskDao.getPendingChanges()
        pending.forEach { task ->
            when (task.syncStatus) {
                SyncStatus.PENDING_CREATE -> api.createTask(task.toDto())
                SyncStatus.PENDING_UPDATE -> api.updateTask(task.toDto())
                SyncStatus.PENDING_DELETE -> api.deleteTask(task.id)
                SyncStatus.SYNCED -> Unit
            }
            taskDao.markSynced(task.id)
        }

        // Then pull remote changes
        val remoteTasks = api.getTasksUpdatedSince(taskDao.getLastSyncTimestamp())
        taskDao.mergeRemote(remoteTasks)

        return Result.success()
    }
}

This runs as a periodic WorkManager job, plus a one-off trigger whenever connectivity is restored (via a NetworkType.CONNECTED constraint). Push-then-pull ordering matters: pushing first means the server sees your local changes before you potentially overwrite them with a remote pull.

Step 3: Handling conflicts honestly

Conflicts happen whenever the same task was edited on two devices while one was offline. Three real strategies, in order of implementation cost:

  • Last-write-wins — compare updatedAt timestamps, keep the newer one. Simple, and correct often enough for single-user apps where "two devices, one offline" is rare.
  • Field-level merging — instead of replacing the whole record, merge individual changed fields (title changed here, isDone changed there). Better UX, meaningfully more implementation work.
  • CRDTs / operational transforms — for genuinely collaborative, multi-user real-time editing (think Google Docs). Overkill for a personal task app, essential for anything with concurrent multi-user edits.

For most apps, last-write-wins with a visible "this was updated elsewhere" indicator is the right cost-to-value trade-off. Don't reach for CRDTs unless the product genuinely needs simultaneous multi-user editing — it's a lot of complexity to carry for a problem you might not have.

Step 4: Surfacing sync state to the user

Silent sync failures erode trust fast. A small sync-status indicator per item (synced / pending / failed) — driven directly by the syncStatus column already in the local DB — costs almost nothing to build and answers the single most common offline-app support question: "did my change actually save?"

Step 5: Testing offline behavior deliberately

Offline bugs are notoriously easy to miss in manual testing because most development happens on a stable Wi-Fi connection. Worth building in from day one:

  • A debug-only "airplane mode simulator" toggle that forces the network layer to fail, without touching the device's actual radio.
  • Unit tests for the merge logic specifically — feed it two conflicting records and assert the resolution matches your chosen strategy.
  • An integration test that creates records offline, "reconnects," and asserts the final state matches what the server would produce.

The takeaway

Offline-first isn't caching bolted onto a network-first app — it's a design decision made at the data-layer level, before a single screen gets built. Get the local-DB-as-source-of-truth pattern right early, and most of what looks like "offline support" downstream turns out to already work for free.

Coding