DEV Community

Cover image for Offline-First Android: Build Apps That Keep Working When the Network Doesn’t
Mohan Sankaran
Mohan Sankaran

Posted on

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

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 background
  • When Room updates, the UI updates (because it’s collecting a Flow)

2) Writes: Outbox pattern

  • Save user edits to Room immediately
  • Mark records as PENDING
  • Append an operation to an outbox table
  • WorkManager uploads when connectivity allows
  • On success, mark as SYNCED
  • On conflict, mark as CONFLICTED (or apply your merge rule)

Data model: domain + sync metadata

We’ll use different names throughout:

  • Clip = the thing users view/edit
  • VaultDb = Room database
  • CourierApi = Retrofit API
  • DispatchSyncWorker = WorkManager worker

Sync state enums

enum class SyncFlag { SYNCED, PENDING, SYNCING, CONFLICTED }
enum class OpKind { UPSERT, DELETE }
Enter fullscreen mode Exit fullscreen mode

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

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

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
    @Query("""
        DELETE FROM clips
        WHERE syncFlag NOT IN ('PENDING','CONFLICTED')
          AND lastOpenedAt < :cutoff
    """)
    suspend fun cleanupOldClips(cutoff: Long)
}
Enter fullscreen mode Exit fullscreen mode

Room database: VaultDb

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(
    entities = [ClipEntity::class, OutboxOp::class],
    version = 1,
    exportSchema = true
)
abstract class VaultDb : RoomDatabase() {
    abstract fun clipDao(): ClipDao
}
Enter fullscreen mode Exit fullscreen mode

Network layer: Retrofit API (example)

Ideally your backend supports delta sync (token / cursor / ETags). We’ll model a token-based pull.

data class ClipDto(
    val clipId: String,
    val title: String,
    val body: String,
    val serverUpdatedAt: Long
)

data class SyncPullResponse(
    val clips: List<ClipDto>,
    val deletedIds: List<String>,
    val newSyncToken: String
)

interface CourierApi {
    // Push
    suspend fun upsertClip(dto: ClipDto): ClipDto
    suspend fun deleteClip(clipId: String)

    // Pull
    suspend fun pullChanges(syncToken: String?): SyncPullResponse
}
Enter fullscreen mode Exit fullscreen mode

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

        // You might keep a "deleted" flag instead. This keeps the example short.
        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()
    }
}

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

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

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
import java.util.UUID

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

                        dao.deleteOutbox(op.opId)

                        val now = clock()
                        dao.upsertClip(
                            ClipEntity(
                                clipId = saved.clipId,
                                title = saved.title,
                                body = saved.body,
                                localUpdatedAt = now,
                                serverUpdatedAt = saved.serverUpdatedAt,
                                cachedAt = now,
                                lastOpenedAt = now,
                                syncFlag = SyncFlag.SYNCED
                            )
                        )
                    }
                    OpKind.DELETE -> {
                        api.deleteClip(op.clipId)
                        dao.deleteOutbox(op.opId)
                        // You may also delete locally or keep tombstones.
                    }
                }
            }

            // 2) PULL deltas
            val token = syncTokenStore.readToken()
            val pull = api.pullChanges(token)

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

            // Apply upserts
            val now = clock()
            val entities = pull.clips.map { dto ->
                ClipEntity(
                    clipId = dto.clipId,
                    title = dto.title,
                    body = dto.body,
                    localUpdatedAt = now,
                    serverUpdatedAt = dto.serverUpdatedAt,
                    cachedAt = now,
                    lastOpenedAt = now,
                    syncFlag = SyncFlag.SYNCED
                )
            }
            if (entities.isNotEmpty()) dao.upsertClips(entities)

            syncTokenStore.writeToken(pull.newSyncToken)

            Result.success()
        } catch (e: Exception) {
            // UI stays on cached data; sync retries with WorkManager backoff
            Result.retry()
        }
    }
}

// Placeholder parsing helpers (use Moshi/kotlinx.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)
}
Enter fullscreen mode Exit fullscreen mode

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

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-dupe).
  • Never delete PENDING or CONFLICTED data during cleanup.
  • For deletions, consider tombstones if you need reliable cross-device consistency.
  • Prefer delta sync (token/ETag) over full refresh.

Closing

Offline-first is a reliability promise:

“Your work and your screen won’t vanish just because the network changed its mind.”

With Room + Flow + WorkManager, you already have the toolbox. The architecture is mostly a mindset shift: treat connectivity as variable, and design for progress anyway.

Top comments (0)