Offline-First Architecture Explained
DATA › Offline
Offline-first means the app is fully usable without a network connection, not just 'tolerates' being offline. The foundational decision that makes this possible: the **local database is the single source of truth**. The UI and domain layers never read the network directly, they read the local store (typically Room), and the repository is the only thing allowed to write network responses back into it:
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
}
}
Every other pattern in this topic, caching, syncing, conflict resolution, exists to keep that one guarantee true: whatever the local DB says is what the UI shows, online or off.
The pattern that implements 'source of truth' well is **stale-while-revalidate** (often called NetworkBoundResource): show the cached local value immediately, then refresh from the network in the background and let the same observable stream re-emit once the fresh data lands.
fun getArticle(id: String): Flow<Article> = flow {
dao.getArticle(id)?.let { emit(it) } // 1. instant, possibly stale
val fresh = api.fetchArticle(id) // 2. revalidate
dao.upsert(fresh)
emit(fresh) // 3. UI updates again automatically
}
The UI never blocks on the network round-trip, it renders the cached value the instant the screen opens, then updates seamlessly if something changed server-side. This is exactly what makes the app feel instant even on a slow connection, and it's the pattern interviewers expect you to name when they ask 'how would you design the data layer here?'
Not every write should behave the same way offline. There are three common strategies, and the right one depends on how much you can afford to lose or delay:
- **Online-only**: write to the network first, only update local on confirmed success. Use for anything that must not silently succeed offline, like a payment or a bank transfer. - **Queued**: enqueue the write and drain the queue when connectivity returns, tolerating that it might eventually fail. Fits things like analytics events, where losing one occasionally is fine. - **Lazy (local-first)**: write to local immediately (instant UI feedback), then queue a background sync to push it to the server. Fits user-authored data you must never lose, like a note or a draft.
// Lazy write: instant local update, sync queued in the background
suspend fun saveNote(note: Note) {
___
workManager.enqueue(syncRequestFor(note.id))
}
Background sync needs to survive the app being backgrounded, killed, or the device rebooting mid-sync, a coroutine launched in viewModelScope dies with the ViewModel. WorkManager is built for exactly this: it persists enqueued work, supports constraints like requiring a network connection, and retries transient failures with exponential backoff.
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result = try {
repository.pushPendingChanges()
Result.success()
} catch (e: IOException) {
Result.retry() // transient, WorkManager backs off and retries
} catch (e: Exception) {
Result.failure() // not recoverable, stop retrying
}
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
Returning Result.retry() from a recoverable failure is the key move, it hands scheduling back to WorkManager instead of you writing your own retry loop.
Once writes can happen offline and sync later, two devices (or a device and the server) can legitimately disagree about the latest state, that's a **conflict**, and you need a deliberate strategy for resolving it.
The simplest is **last-write-wins**: every write carries a timestamp, and whichever is newest survives.
val winner = listOf(localEdit, serverEdit).maxBy { it.updatedAt }
It's easy to implement but has a real cost: it can silently discard a concurrent edit with no notification to either user. For deletions specifically, don't hard-delete locally, use a **tombstone**, a soft-delete flag, so the deletion itself has something to sync: a hard local delete leaves no record, and a later pull from the server could resurrect the row. And for writes that might be retried after a dropped response, attach a client-generated id (a UUID) so the server can recognize a retry and avoid applying the same write twice.
Because the local DB is the source of truth, exposing reads as Flow or StateFlow rather than one-shot suspend calls is what makes the write-then-observe loop work: a write from anywhere (a background sync, a local edit) automatically causes every observer to re-emit, no manual refresh needed. But a Flow reading from disk can still throw, a Room query can fail, so wrap it with catch and map failures into an explicit UI state instead of letting the app crash:
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>
}
dao.getAllFlow()
.map<List<Note>, Lce<List<Note>>> { Lce.Content(it) }
.catch { e -> emit(Lce.Error(e)) }
.collect { state -> _uiState.value = state }
LCE, Loading or Content or Error, gives the UI a single sealed type to render against instead of juggling nullable data and a separate error flag.
The last design axis is how you decide *when* to fetch: pull-based or push-based.
**Pull-based** fetches on demand, exactly what Jetpack Paging's RemoteMediator does for a paged list: it loads network pages and writes them into Room, and Paging then serves the UI from that local cache, so already-loaded pages stay available offline.
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 items = api.getArticles(page = nextPageFor(loadType, state))
db.articleDao().insertAll(items)
return MediatorResult.Success(endOfPaginationReached = items.isEmpty())
}
}
**Push-based** instead seeds a full baseline up front and reacts to server-sent change signals with versioning. Pull suits brief offline periods and avoids over-fetching; push suits long offline stretches and relational data, at the cost of needing real versioning and server support for change signals.
The whole topic is one idea applied at every layer: **make the local database the contract, and design every other piece to protect it**. The repository reads and writes only through the local store, never handing the network directly to the UI. Stale-while-revalidate keeps that store fresh without blocking a render. Write strategies (online-only, queued, lazy) match the write's actual risk tolerance. WorkManager gives sync durability that a ViewModel coroutine can't. Conflict resolution (last-write-wins, tombstones, client-generated ids) decides what happens when two writers disagree. Flow or StateFlow plus catch-driven LCE state keeps the UI reactive and crash-resistant. And pull versus push decides how aggressively you keep that local store in sync with the server. An interviewer asking 'design an offline-first app' is really asking you to walk this chain and justify each choice for their specific data.