Offline-First Android: Build Apps That Keep Working When the Network Doesn’t

Published: (December 27, 2025 at 04:39 AM EST)
7 min read
Source: Dev.to

Source: Dev.to

Introduction

Mobile connectivity isn’t “good” or “bad” — it’s inconsistent. Shopping malls, subway tunnels, packed airports, national‑park campsites, and long rural interstate stretches… your users live in all of it.

If your app’s main flow is “call API → render UI”, then every timeout turns into:

  • spinners,
  • retry buttons,
  • and users losing trust.

Offline‑first changes the contract: your UI stays functional even when the network doesn’t. The network becomes a sync channel, not a dependency for basic UX.

In this post we’ll build an offline‑first shape you can ship:

  • Room as UI source of truth
  • Reads via stale‑while‑revalidate (SWR)
  • Writes via the Outbox pattern
  • Background sync via WorkManager

The mental model

Online‑first (fragile)

UI loads data by calling the server; if the network fails, the screen fails.

If the API fails, the screen fails.

Offline‑first (resilient)

UI reads from Room instantly; WorkManager syncs with the server in the background.

  • Room is what the UI trusts.
  • The network is what eventually makes your local state match the server.

Core patterns: SWR + Outbox

1️⃣ Reads – Stale‑While‑Revalidate (SWR)

  • UI renders immediately from Room.
  • Repository decides if data is “old enough” to refresh.
  • Refresh runs in the background.
  • When Room updates, the UI updates (because it’s collecting a Flow).

2️⃣ Writes – Outbox pattern

  1. Save user edits to Room immediately and mark records as PENDING.
  2. Append an operation to an outbox table.
  3. WorkManager uploads when connectivity allows.
  4. On success, mark as SYNCED.
  5. On conflict, mark as CONFLICTED (or apply your merge rule).

Data model: domain + sync metadata

We’ll use different names throughout:

SymbolMeaning
ClipThe thing users view/edit
VaultDbRoom database
CourierApiRetrofit API
DispatchSyncWorkerWorkManager worker

Sync‑state enums

enum class SyncFlag { SYNCED, PENDING, SYNCING, CONFLICTED }
enum class OpKind   { UPSERT, DELETE }

Room entity – ClipEntity

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "clips")
data class ClipEntity(
    @PrimaryKey val clipId: String,      // client‑generated UUID
    val title: String,
    val body: String,

    // sync metadata
    val localUpdatedAt: Long,            // device time when user edited
    val serverUpdatedAt: Long?,          // server timestamp if known

    val cachedAt: Long,                  // last successful fetch time
    val lastOpenedAt: Long,              // for cleanup / LRU
    val syncFlag: SyncFlag               // SYNCED / PENDING / …
)

Outbox – OutboxOp (queued operations)

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "outbox_ops")
data class OutboxOp(
    @PrimaryKey val opId: String,        // UUID
    val clipId: String,
    val kind: OpKind,                    // UPSERT / DELETE
    val payloadJson: String?,            // null for delete
    val sequence: Long,                  // ordering per clip
    val attempts: Int,
    val lastAttemptAt: Long?
)

DAO – Flows for UI + queries for sync

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface ClipDao {

    // UI reads
    @Query("SELECT * FROM clips ORDER BY lastOpenedAt DESC")
    fun observeClips(): Flow<List<ClipEntity>>

    @Query("SELECT * FROM clips WHERE clipId = :id LIMIT 1")
    fun observeClip(id: String): Flow<ClipEntity?>

    // Writes
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertClip(item: ClipEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertClips(items: List<ClipEntity>)

    // Outbox
    @Query("SELECT * FROM outbox_ops ORDER BY clipId ASC, sequence ASC")
    suspend fun loadOutboxOrdered(): List<OutboxOp>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun enqueueOutbox(op: OutboxOp)

    @Query("DELETE FROM outbox_ops WHERE opId = :opId")
    suspend fun deleteOutbox(opId: String)

    // Deletions from server pull
    @Query(
        """
        DELETE FROM clips 
        WHERE clipId IN (:ids) 
          AND syncFlag NOT IN ('PENDING','CONFLICTED')
        """
    )
    suspend fun deleteClipsIfSafe(ids: List<String>)

    // Helpers
    @Query("SELECT MAX(cachedAt) FROM clips")
    suspend fun lastCacheTime(): Long?

    @Query("UPDATE clips SET lastOpenedAt = :now WHERE clipId = :id")
    suspend fun touchClip(id: String, now: Long)

    // Cleanup (example placeholder – adjust as needed)
    @Query(
        """
        DELETE FROM clips
        WHERE syncFlag NOT IN ('PENDING','CONFLICTED')
          AND lastOpenedAt < :threshold
        """
    )
    suspend fun cleanupOldEntries(threshold: Long)
}

Repository: SWR reads + Outbox writes

The repository is your policy brain:

  • UI always reads from Room
  • Refresh happens in the background based on cache age
  • Writes update Room immediately and enqueue outbox ops
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import java.util.UUID

class VaultRepository(
    private val dao: ClipDao,
    private val syncScheduler: SyncScheduler,
    private val clock: () -> Long,
    private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO)
) {
    fun observeClips(): Flow<List<ClipEntity>> {
        ioScope.launch { maybeRefresh() }
        return dao.observeClips()
    }

    fun observeClip(id: String): Flow<ClipEntity?> {
        ioScope.launch { dao.touchClip(id, clock()) }
        return dao.observeClip(id)
    }

    suspend fun saveClip(title: String, body: String, existingId: String? = null) {
        val id = existingId ?: UUID.randomUUID().toString()
        val now = clock()

        val entity = ClipEntity(
            clipId = id,
            title = title,
            body = body,
            localUpdatedAt = now,
            serverUpdatedAt = null,
            cachedAt = now,
            lastOpenedAt = now,
            syncFlag = SyncFlag.PENDING
        )
        dao.upsertClip(entity)

        val op = OutboxOp(
            opId = UUID.randomUUID().toString(),
            clipId = id,
            kind = OpKind.UPSERT,
            payloadJson = """{"clipId":"$id","title":${title.json()},"body":${body.json()},"serverUpdatedAt":0}""",
            sequence = now,
            attempts = 0,
            lastAttemptAt = null
        )
        dao.enqueueOutbox(op)

        syncScheduler.kick()
    }

    suspend fun removeClip(id: String) {
        val now = clock()
        val op = OutboxOp(
            opId = UUID.randomUUID().toString(),
            clipId = id,
            kind = OpKind.DELETE,
            payloadJson = null,
            sequence = now,
            attempts = 0,
            lastAttemptAt = null
        )
        dao.enqueueOutbox(op)
        syncScheduler.kick()
    }

    private suspend fun maybeRefresh() {
        val now = clock()
        val last = dao.lastCacheTime() ?: 0L
        val ageMs = now - last

        val shouldRefresh = when {
            last == 0L -> true
            ageMs > 15 * 60_000L -> true   // 15 minutes
            else -> false
        }

        if (shouldRefresh) syncScheduler.refreshSoon()
    }
}

/** Escape a string for JSON. */
private fun String.json(): String =
    buildString {
        append('"')
        for (ch in this@json) {
            when (ch) {
                '\\' -> append("\\\\")
                '"'  -> append("\\\"")
                '\n' -> append("\\n")
                '\r' -> append("\\r")
                '\t' -> append("\\t")
                else -> append(ch)
            }
        }
        append('"')
    }

Scheduling sync with WorkManager

SyncScheduler

import android.content.Context
import androidx.work.*

class SyncScheduler(private val context: Context) {

    fun kick() = enqueueUnique("vault_sync_now")
    fun refreshSoon() = enqueueUnique("vault_sync_refresh")

    private fun enqueueUnique(name: String) {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

        val request = OneTimeWorkRequestBuilder<DispatchSyncWorker>()
            .setConstraints(constraints)
            .setBackoffCriteria(
                BackoffPolicy.EXPONENTIAL,
                30_000L,
                java.util.concurrent.TimeUnit.MILLISECONDS
            )
            .build()

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

Worker: push outbox → pull deltas → update Room

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.Result
import kotlinx.coroutines.delay

class DispatchSyncWorker(
    appContext: Context,
    params: WorkerParameters,
    private val db: VaultDb,
    private val api: CourierApi,
    private val syncTokenStore: SyncTokenStore,
    private val clock: () -> Long
) : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        // Helps with flappy “connected for 1 second” networks
        delay(1500)

        val dao = db.clipDao()

        return try {
            // 1) PUSH pending ops in order
            val outbox = dao.loadOutboxOrdered()
            for (op in outbox) {
                when (op.kind) {
                    OpKind.UPSERT -> {
                        val dto = op.payloadJson!!.toClipDto()
                        val saved = api.upsertClip(dto.copy(serverUpdatedAt = 0L))
                        // Update local entity as SYNCED
                        dao.upsertClip(
                            ClipEntity(
                                clipId = saved.clipId,
                                title = saved.title,
                                body = saved.body,
                                localUpdatedAt = clock(),
                                serverUpdatedAt = saved.serverUpdatedAt,
                                cachedAt = clock(),
                                lastOpenedAt = clock(),
                                syncFlag = SyncFlag.SYNCED
                            )
                        )
                    }
                    OpKind.DELETE -> {
                        api.deleteClip(op.clipId)
                        // Optionally delete locally or keep tombstone
                    }
                }
                // Mark attempt
                dao.updateOutboxAfterAttempt(op.opId, clock())
            }

            // 2) PULL deltas since last token
            val token = syncTokenStore.readToken()
            val response = api.pullChanges(token)

            // Apply incoming upserts
            response.clips.forEach { dto ->
                dao.upsertClip(
                    ClipEntity(
                        clipId = dto.clipId,
                        title = dto.title,
                        body = dto.body,
                        localUpdatedAt = clock(),
                        serverUpdatedAt = dto.serverUpdatedAt,
                        cachedAt = clock(),
                        lastOpenedAt = clock(),
                        syncFlag = SyncFlag.SYNCED
                    )
                )
            }

            // Apply deletions safely (don’t delete pending/conflicted rows)
            if (response.deletedIds.isNotEmpty()) {
                dao.deleteClipsIfSafe(response.deletedIds)
            }

            // Store new sync token
            syncTokenStore.writeToken(response.newSyncToken)

            Result.success()
        } catch (e: Exception) {
            // Backoff will be handled by WorkManager's policy
            Result.retry()
        }
    }
}

/* Helper extensions – replace with proper serialization in production */
private fun String.toClipDto(): ClipDto {
    val id = Regex(""""clipId"\s*:\s*"([^"]+)"""").find(this)?.groupValues?.get(1)
        ?: UUID.randomUUID().toString()
    val title = Regex(""""title"\s*:\s*"([^"]*)"""").find(this)?.groupValues?.get(1) ?: ""
    val body = Regex(""""body"\s*:\s*"([^"]*)"""").find(this)?.groupValues?.get(1) ?: ""
    return ClipDto(id, title, body, 0L)
}

interface SyncTokenStore {
    fun readToken(): String?
    fun writeToken(token: String)
}

UI hookup (ViewModel)

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class VaultViewModel(private val repo: VaultRepository) : ViewModel() {

    val clips = repo.observeClips()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun onSave(title: String, body: String) = viewModelScope.launch {
        repo.saveClip(title, body)
    }

    fun onDelete(id: String) = viewModelScope.launch {
        repo.removeClip(id)
    }
}

Sync sequence (end‑to‑end)

User edits → save locally + queue outbox → background worker pushes changes and pulls updates later.


Practical guardrails

  • Don’t trigger sync only on connectivity changes; use backoff and avoid storms.
  • Keep writes idempotent (client UUIDs + server‑side de‑duplication).
  • Never delete PENDING or CONFLICTED data during cleanup.
  • For deletions, consider tombstones if you need reliable cross‑device consistency.
Back to Blog

Related posts

Read more »