Offline-First Architecture Explained

DATA › Offline

Offline-first does not mean an app merely tolerates a dropped connection, it means the app is fully usable with no connection at all: reading, writing, everything. That only works if there is one rule every layer follows. The local database, almost always Room, is the single source of truth. The UI and the domain layer never talk to the network directly. They read from the local store, and the repository is the only piece of code allowed to take a network response and write it back into that store. When a refresh happens, the network result gets written into Room first, and the screen finds out because it is already observing Room, not because the network call handed it anything directly.

This one rule is what every other pattern in this lesson exists to protect. Caching, background sync, conflict resolution, all of it is scaffolding around a single guarantee: whatever the local database says is what the UI shows, online or off. If you can't say exactly where a given screen's data comes from, and the answer isn't 'the local database, always', the architecture isn't offline-first yet, it's just a networked app with some caching bolted on.

class NoteRepository(private val dao: NoteDao, private val api: NoteApi) {
    fun getNotes(): Flow<List<Note>> = dao.getAll()    // UI reads only this

    suspend fun refresh() {
        val fresh = api.fetchNotes()
        dao.upsertAll(fresh)                            // network writes here, never straight to UI
    }
}

Once the local database is the source of truth, the obvious next question is how you keep it fresh without making the user wait on a network round trip every time a screen opens. The answer interviewers are looking for is stale-while-revalidate, sometimes implemented as a NetworkBoundResource: emit whatever is already sitting in the local store immediately, even if it might be a little out of date, then fetch fresh data from the network in the background and write it into that same local store. Because the UI is observing that store rather than a one-shot network call, it automatically re-renders when the fresh value lands.

The UI never blocks on the network. It renders the cached value the instant the screen opens, then updates seamlessly if anything changed on the server. That's what makes an offline-first app feel instant even on a slow connection, and it is the exact pattern to name when someone asks how you would design the data layer for a feed or a detail screen.

fun getArticle(id: String): Flow<Article> = flow {
    dao.getArticle(id)?.let { emit(it) }   // 1. instant, possibly stale
    val fresh = api.fetchArticle(id)        // 2. revalidate in the background
    dao.upsert(fresh)
    emit(fresh)                             // 3. observers update again automatically
}

Not every write should behave the same way while offline, and picking the wrong strategy is a common interview stumble. There are three common approaches, and the right one depends entirely on what you can afford to lose or delay. Online-only sends the write to the network first and only updates the local store once the server confirms success, so use it for anything that must never silently appear to succeed while offline, a payment or a bank transfer being the classic case. Queued enqueues the write and drains the queue once connectivity returns, tolerating that a queued item might eventually fail outright, which fits something like an analytics event where losing one occasionally is fine. Lazy, or local-first, writes to the local store immediately for instant UI feedback and then queues a background sync, which fits user-authored data you must never lose, like a note or a draft.

For the bank transfer specifically, the failure mode that matters is a write that looks like it succeeded to the user but never actually reached the server. Only online-only prevents that, because it refuses to update anything locally until the server has actually confirmed.

// Online-only write: network first, local updated only on confirmed success
suspend fun transfer(amount: Money): Result<Unit> = try {
    val tx = api.postTransfer(amount)      // must succeed remotely
    localDao.recordTransaction(tx)          // update local only after confirmation
    Result.success(Unit)
} catch (e: Exception) {
    Result.failure(e)                       // surface the error immediately, no local change
}

The lazy, local-first strategy deserves a closer look because it is the one candidates most often misapply. It writes to the local database immediately, which is what makes the UI feel instant, a saved note shows up on screen before any network call has even started, and then it queues a background sync to push that change to the server whenever connectivity allows. The trade-off is real: for a short window, the local store can be ahead of the server. That trade-off is exactly right for something like editing a note, where losing the edit is unacceptable but waiting on the network before showing the update would make the app feel broken. It is the wrong choice for something like a payment, where an update that only exists locally and never reaches the server is a serious problem, not a minor inconvenience.

// Lazy (local-first) write: update local immediately, sync in the background
suspend fun saveNote(note: Note) {
    noteDao.upsert(note)                                    // instant local write, UI updates now
    WorkManager.getInstance(context).enqueue(
        OneTimeWorkRequestBuilder<SyncWorker>()
            .setInputData(workDataOf("noteId" to note.id))
            .build()
    )                                                        // background sync queued
}

Queued and lazy writes both depend on background sync actually happening later, and that raises a practical problem: where does that background work live so it survives the user backgrounding the app, the process being killed, or the device rebooting mid-sync? A coroutine launched in viewModelScope is cancelled the moment its ViewModel is cleared or the process dies, so it cannot be trusted with a write that absolutely has to reach the server eventually. WorkManager exists to solve exactly this. It persists enqueued work outside the process, so a job survives the app being killed and even a reboot, it supports constraints like requiring network connectivity before it starts, and it retries transient failures automatically using exponential backoff, all without you hand-writing a retry loop.

// BAD: cancelled the moment the process dies or the app is fully backgrounded
viewModelScope.launch {
    repository.sync()   // lost if the process is killed mid-flight
}

// GOOD: durable, constraint-aware, retried automatically by WorkManager
WorkManager.getInstance(context)
    .enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build())

WorkManager's durability is only half the story, the other half is the constraints you attach to a request so it doesn't even try to run under the wrong conditions. The most common one for sync is requiring network connectivity: without it, WorkManager might attempt the job while the device is offline, fail immediately, and burn a retry attempt for nothing. Constraints are built with a Constraints.Builder and attached to the work request before it's enqueued, and WorkManager holds the job until every constraint it lists is satisfied.

val constraints = Constraints.Builder()
    .setRequiredNetworkType(___)
    .build()

val request = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(constraints)
    .build()

WorkManager.getInstance(context).enqueue(request)

Inside the work itself, doWork() has exactly three meaningful outcomes, and picking the right one for a given failure is a favorite interview probe. Result.success() means the sync fully completed. Result.failure() means the work failed in a way that retrying won't fix, so WorkManager should give up and not schedule it again. Result.retry() means the failure looks temporary, a dropped connection or a server hiccup, so WorkManager should reschedule the work using its exponential backoff policy rather than you writing your own delay-and-retry logic. Mixing these up is a real bug: returning a permanent failure for a transient network blip means a write that should have eventually synced never will.

class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result = try {
        repository.sync()
        Result.success()
    } catch (e: IOException) {
        ___          // this failure is transient, a dropped connection mid-request
    } catch (e: Exception) {
        Result.failure()    // not recoverable, stop retrying
    }
}

There's a second, quieter bug that shows up around periodic sync: if the app calls its sync setup code on every launch, and that code just calls enqueue() each time, you end up with multiple overlapping periodic sync chains all running independently, each doing the same work. WorkManager's fix for this is unique work: enqueue the periodic request under a stable name using enqueueUniquePeriodicWork, along with a policy like ExistingPeriodicWorkPolicy.KEEP, which tells WorkManager to leave the existing chain alone if one is already scheduled under that name rather than starting a second one. The setup code can then run safely on every single launch, because WorkManager, not your app, is the one deciding whether a new job is actually needed.

// Safe to call on every app launch, WorkManager deduplicates by name
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "periodic_sync",                          // stable name, only one instance may exist
    ExistingPeriodicWorkPolicy.KEEP,           // ignore this enqueue if one is already scheduled
    PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS).build()
)

Sync durability solves getting a write to the server eventually, but it doesn't solve what happens when two writers, two devices, or a device and the server, disagree about the latest state of the same record because both wrote to it while the other was offline. That disagreement is a conflict, and it needs a deliberate resolution strategy, not an accident of whichever write happens to land last. The simplest strategy is last-write-wins: every write carries a timestamp, and whichever timestamp is newest is treated as correct. It's easy to reason about and easy to implement, but it has a real cost worth stating plainly, because it's the classic gotcha here: the losing write isn't merged or flagged, it's just gone, with no signal to either user that a conflict ever happened.

// Both users edit the same note about a millisecond apart, while both were offline
val userA = Note(id = "1", text = "Hello", updatedAt = 1_000L)
val userB = Note(id = "1", text = "World", updatedAt = 1_001L)

// Last-write-wins keeps the later timestamp; userA's edit is silently discarded
val winner = listOf(userA, userB).maxBy { it.updatedAt }
// winner.text == "World"; "Hello" is gone, with no conflict notification to anyone

Last-write-wins handles disagreement about content, but there's a separate failure mode worth guarding against: the same write reaching the server twice. Picture a queued write that actually succeeds on the server, but the success response never makes it back to the device, maybe the connection drops right after the server processed it. WorkManager will retry that write, because as far as the client knows it failed. Without something identifying the write itself, the server has no way to tell 'this is the same write arriving again' from 'this is a genuinely new write', and it ends up applying it twice. The fix is to generate a stable client-side id, typically a UUID, once when the write is first created, and send that same id on every retry, so the server can recognize a duplicate and simply return the original result instead of processing it again.

data class CreateNoteRequest(
    val clientId: String = UUID.randomUUID().toString(), // generated once, stable across retries
    val title: String,
    val body: String
)

// Server: if clientId was already recorded, return the existing result and skip a second insert

Deletions need their own care, because a delete offline is really two different things: making the record disappear on this device right now, and telling the server about it later. If you hard-delete the row locally the moment the user taps delete, there is nothing left for the sync layer to send, the intent to delete has vanished along with the row. Worse, if a pull from the server happens before that delete ever syncs, the server's copy of the record can come right back down and resurrect it. The fix is a tombstone: instead of removing the row, mark it deleted with a flag and a timestamp. The row stays in the local database exactly so there is something for sync to push to the server, and only after the server confirms the deletion does the row get hard-deleted for real.

@Entity
data class Note(
    @PrimaryKey val id: String,
    val title: String,
    val isDeleted: Boolean = false,       // tombstone flag
    val deletedAt: Long? = null
)

// Soft-delete: the row stays in the DB so the sync layer has something to push
suspend fun deleteNote(id: String) {
    noteDao.markDeleted(id, deletedAt = System.currentTimeMillis())
    // hard DELETE only happens after the server confirms, preventing resurrection on a later pull
}

Everything so far has assumed the UI finds out about local changes automatically, and that only works because reads are exposed as Flow or StateFlow rather than one-shot suspend calls. A write from anywhere, a background sync, a local edit, a conflict resolution, causes every observer of that query to re-emit on its own, no manual refresh required, which is what makes the write-then-observe loop actually hold together. But a Flow reading off disk can still fail, a Room query can throw, so that possibility needs to be handled without letting it crash the app. The catch operator sits upstream of collect and intercepts an exception before it propagates, letting the code emit a replacement value instead, typically an explicit error state modeled with a sealed type like LCE, loading, content, or error, so the UI has one consistent shape to render against instead of juggling nullable data and a separate error flag.

sealed interface Lce<out T> {
    data class Content<T>(val data: T) : Lce<T>
    data class Error(val throwable: Throwable) : Lce<Nothing>
    object Loading : Lce<Nothing>
}

noteDao.getAllFlow()
    .map<List<Note>, Lce<List<Note>>> { Lce.Content(it) }
    .catch { e -> emit(Lce.Error(e)) }     // exception caught, the stream stays alive
    .collect { state -> _uiState.value = state }

Paged lists add one more wrinkle: how do you keep a long, scrollable list available offline without loading the entire dataset up front? Jetpack Paging's RemoteMediator is the piece that makes this work for an offline-first list. It is a pull-based bridge: when Paging needs more items, RemoteMediator fetches the next page from the network and writes those items into Room, the exact same local store everything else in this lesson has been protecting. Paging itself never talks to the network directly, it serves the UI from Room. The practical payoff is that pages the user has already scrolled through stay visible even if the network drops later, because they're sitting in the local database, not held in some memory-only cache that would otherwise be discarded once it scrolls off screen.

class ArticleRemoteMediator(
    private val api: ArticleApi,
    private val db: AppDatabase
) : RemoteMediator<Int, Article>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, Article>): MediatorResult {
        val page = nextPageFor(loadType, state)
        val items = api.getArticles(page = page)
        db.articleDao().insertAll(items)          // write the network page into Room
        return MediatorResult.Success(endOfPaginationReached = items.isEmpty())
    }
}

RemoteMediator is one specific instance of a bigger design decision: do you pull data on demand, or push it proactively? Pull-based fetching, what RemoteMediator does, asks the network for data only when the UI actually needs it, which avoids over-fetching and suits an app that's only offline for short stretches at a time. Push-based sync works the other way: seed a full baseline once, up front, and then react to change signals the server sends out, using version numbers or change tokens to know exactly what changed since the last sync, rather than re-asking the server whether anything is new on a timer. Push costs more to build, it needs real versioning and server support for change signals, but it's the better fit for an app that can be offline for days and syncs relational data across devices, because it doesn't depend on the client happening to be online at the right moment to notice a change.

Back to Offline-First Architecture