Android System Design Interview Questions
How do you build an offline-first app? Explain the architecture.
Tier: EssentialDifficulty: Hard
Offline-first means the local database is the source of truth the UI always reads from. The network is just one of the things that keeps that database up to date, not something the UI waits on. Get that inversion right and most of the rest of the architecture follows from it.
What I'd clarify first
- Is this read-mostly, caching data the user views, or does it also need offline writes, actions taken with no connection that sync later.
- If writes are in scope, can two devices edit the same record while both are offline. That is what turns this into a conflict resolution problem rather than a caching one.
- How stale is acceptable. Is "last synced 10 minutes ago" fine, or does the product need to actively signal staleness to the user.
The architecture
- A local database as the single source of truth, Room, backing every screen's UI state. The UI never talks to the network directly. It observes a
Flowfrom Room, and Room always has an answer the instant a screen asks, even a stale one. - A repository layer that owns when to go to the network, on screen open, on pull-to-refresh, or on a background schedule. Whatever comes back is written straight into Room. The UI never learns whether it is looking at a cache or a fresh call. It re-renders when Room emits a new value, and that is all it knows.
- A sync engine built on
WorkManager, for anything that has to survive the app being backgrounded or killed. That covers periodic refresh and, critically, flushing queued offline writes once connectivity returns. Its constraints, network required and battery not low, mean you are not reimplementing "wait for a good time to sync" from scratch. The worker itself lives in the sync answer linked below. - An outbox for offline writes, if writes are in scope, a local table of pending mutations, each with a status of pending, syncing or failed. The UI renders it optimistically, so "message sent" shows immediately while the actual sync happens in the background and the row's status updates once it lands.
- Paging, once a list is long enough to need it. The same inversion is
RemoteMediator. Room is still the source of truth, and paging only decides when to ask the network for more.
How a screen loads
A screen subscribes to a Room Flow and renders immediately with whatever is already local. Empty or stale is still something to show, and a blank loading state over data the device already has is the failure this architecture exists to prevent. The repository, on that same screen open, kicks off a network refresh in the background. When the response comes back it is written into Room, and the UI updates because it is observing that table, not because anyone told it to. If the network call fails, nothing happens to the UI. It is already showing the last good local data, so the failure is a small "couldn't refresh" mark rather than a full-screen error.
The code
Reads never touch the network, a refresh writes into the store, and a failed refresh does nothing at all to the UI. That is the pattern in full.
Two decisions are pulled out into their own files, because they are where this design actually goes wrong. The refresh policy decides whether a screen open is worth a request, and it holds off after a failure so an airplane mode phone does not retry on every open. A staleness check on its own is not enough. On a cold start nothing has been fetched yet, so six tabs opening in the same second all pass it, and a single-flight guard is what turns that into one request. The refresh also rethrows cancellation instead of recording it, because a user leaving the screen is not a failed request and must not start a cooloff.
The retention policy is the quiet one. A sync engine that only ever inserts turns offline-first into the app never gives storage back, and it takes months of real use to notice.
Java
com.androidinterview.offlinefirst.cache.RetentionPolicy.java
package com.androidinterview.offlinefirst.cache;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
// The quiet failure mode of every offline first app, and the reason this file
// exists at all. A sync engine that only ever inserts turns offline first into
// the app never gives storage back, and it is invisible in testing because it
// takes months of real use to show.
//
// Two rules together, because either alone leaves a hole. Age catches the rows
// nobody opens, and the cap catches a user who opens far too many.
//
// This shows the rule, not the query. The real version is one DELETE with the
// same two clauses, because the list this sorts in memory is exactly the list
// that got too big to hold.
public final class RetentionPolicy {
public record CacheRow(String id, long lastReadAt) {
}
private final long maxAgeMillis;
private final int maxRows;
public RetentionPolicy(long maxAgeMillis, int maxRows) {
this.maxAgeMillis = maxAgeMillis;
this.maxRows = maxRows;
}
public List<String> evictable(List<CacheRow> rows, long nowMillis) {
List<CacheRow> ordered = new ArrayList<>(rows);
// Id breaks the ties, so which rows survive the cap is the same on
// every run rather than whatever order the query happened to return.
ordered.sort(Comparator.comparingLong(CacheRow::lastReadAt).reversed()
.thenComparing(CacheRow::id));
List<String> evict = new ArrayList<>();
for (int i = 0; i < ordered.size(); i++) {
CacheRow row = ordered.get(i);
if (i >= maxRows || nowMillis - row.lastReadAt() > maxAgeMillis) evict.add(row.id());
}
return evict;
}
}
com.androidinterview.offlinefirst.data.RefreshPolicy.java
package com.androidinterview.offlinefirst.data;
// When to actually go to the network, which is the decision the repository
// exists to own. Refreshing on every screen open is how a tab bar app fires
// six requests a second while the user flicks between tabs.
public final class RefreshPolicy {
public enum Trigger {
SCREEN_OPEN, PULL_TO_REFRESH, BACKGROUND
}
private final long ttlMillis;
private final long failureCooloffMillis;
public RefreshPolicy(long ttlMillis, long failureCooloffMillis) {
this.ttlMillis = ttlMillis;
this.failureCooloffMillis = failureCooloffMillis;
}
public boolean shouldRefresh(long lastFetchedAt, long lastFailureAt, long nowMillis, Trigger trigger) {
// The user asked, so the user gets a request, stale or not. This is the
// one trigger that ignores every rule below it.
if (trigger == Trigger.PULL_TO_REFRESH) return true;
// A cooloff after a failure, so an airplane mode phone does not fire a
// request per screen open forever. WorkManager's retry policy covers
// the background attempts while this covers the foreground ones.
if (lastFailureAt != 0 && nowMillis - lastFailureAt < failureCooloffMillis) return false;
return nowMillis - lastFetchedAt > ttlMillis;
}
}
com.androidinterview.offlinefirst.data.Repository.java
package com.androidinterview.offlinefirst.data;
import com.androidinterview.offlinefirst.data.RefreshPolicy.Trigger;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
// Reads never touch the network, a refresh writes into the store, and a failed
// refresh does nothing at all to the UI. That is the offline first pattern in
// full.
public final class Repository<T> {
public interface Fetcher<T> {
List<T> fetch() throws IOException;
}
private final Store<T> store;
private final Fetcher<T> fetcher;
private final RefreshPolicy policy;
private final AtomicBoolean inFlight = new AtomicBoolean(false);
public Repository(Store<T> store, Fetcher<T> fetcher, RefreshPolicy policy) {
this.store = store;
this.fetcher = fetcher;
this.policy = policy;
}
// Reads never touch the network. Empty or stale is still something to
// show, and a blank loading screen over data that is already on the device
// is the failure this architecture exists to prevent.
public List<T> current() {
return store.read();
}
public boolean refresh(Trigger trigger, long nowMillis) {
if (!policy.shouldRefresh(store.lastFetchedAt(), store.lastFailureAt(), nowMillis, trigger)) return false;
// The other half of the policy, and the half people leave out. On a
// cold start lastFetchedAt is zero, so six tabs opening in the same
// second all pass the check above and all six go to the network. One
// caller wins the flag and the rest return, and they still get the data
// the moment it lands, because every one of them is observing the store.
if (!inFlight.compareAndSet(false, true)) return false;
try {
store.write(fetcher.fetch(), nowMillis);
return true;
} catch (IOException e) {
// A failed refresh does nothing to the UI on purpose. The screen is
// already showing the last good data, so this belongs in a small
// could not refresh mark rather than a full screen error over
// content the user can perfectly well read.
store.recordFailure(nowMillis);
return false;
} finally {
inFlight.set(false);
}
}
}
com.androidinterview.offlinefirst.data.Store.java
package com.androidinterview.offlinefirst.data;
import java.util.List;
import java.util.function.Consumer;
// The local source of truth. Room sits behind this and the listener is a Flow
// in the app, collected by the ViewModel.
//
// The inversion is the whole architecture. The UI subscribes here once and
// re renders whenever the table changes, so it never asks whether what it is
// looking at came from a cache or from the network, and it never waits on a
// request to show something.
public interface Store<T> {
// The real signature returns a Flow, and collecting it is what cancels it,
// so the collector's scope owns the unsubscribe. A bare listener with no
// way to unregister is a leak, and it stands in here only so the sample
// compiles with nothing from Android on the classpath.
void observe(Consumer<List<T>> listener);
List<T> read();
// The write carries the fetch time, because staleness is a property of the
// data and belongs beside it rather than in a separate preferences file
// that can drift out of step with the rows. A successful write also clears
// the failure time, because a cooloff should not outlive the failure.
void write(List<T> values, long fetchedAt);
long lastFetchedAt();
// The failure time is persisted for the same reason the fetch time is. Held
// in a field on the repository it is lost on process death, and surviving
// process death is the premise of the whole architecture.
void recordFailure(long atMillis);
long lastFailureAt();
}
Kotlin
com.androidinterview.offlinefirst.cache.RetentionPolicy.kt
package com.androidinterview.offlinefirst.cache
data class CacheRow(val id: String, val lastReadAt: Long)
// The quiet failure mode of every offline first app, and the reason this file
// exists at all. A sync engine that only ever inserts turns offline first into
// the app never gives storage back, and it is invisible in testing because it
// takes months of real use to show.
//
// Two rules together, because either alone leaves a hole. Age catches the rows
// nobody opens, and the cap catches a user who opens far too many.
//
// This shows the rule, not the query. The real version is one DELETE with the
// same two clauses, because the list this sorts in memory is exactly the list
// that got too big to hold.
class RetentionPolicy(private val maxAgeMillis: Long, private val maxRows: Int) {
// Id breaks the ties, so which rows survive the cap is the same on every
// run rather than whatever order the query happened to return.
fun evictable(rows: List<CacheRow>, nowMillis: Long): List<String> =
rows.sortedWith(compareByDescending<CacheRow> { it.lastReadAt }.thenBy { it.id })
.filterIndexed { index, row -> index >= maxRows || nowMillis - row.lastReadAt > maxAgeMillis }
.map { it.id }
}
com.androidinterview.offlinefirst.data.RefreshPolicy.kt
package com.androidinterview.offlinefirst.data
enum class Trigger { SCREEN_OPEN, PULL_TO_REFRESH, BACKGROUND }
// When to actually go to the network, which is the decision the repository
// exists to own. Refreshing on every screen open is how a tab bar app fires
// six requests a second while the user flicks between tabs.
class RefreshPolicy(private val ttlMillis: Long, private val failureCooloffMillis: Long) {
fun shouldRefresh(lastFetchedAt: Long, lastFailureAt: Long, nowMillis: Long, trigger: Trigger) = when {
// The user asked, so the user gets a request, stale or not.
trigger == Trigger.PULL_TO_REFRESH -> true
// A cooloff after a failure, so an airplane mode phone does not fire a
// request per screen open forever. WorkManager's retry policy covers
// the background attempts and this covers the foreground ones.
lastFailureAt != 0L && nowMillis - lastFailureAt < failureCooloffMillis -> false
else -> nowMillis - lastFetchedAt > ttlMillis
}
}
com.androidinterview.offlinefirst.data.Repository.kt
package com.androidinterview.offlinefirst.data
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.cancellation.CancellationException
// Reads never touch the network, a refresh writes into the store, and a failed
// refresh does nothing at all to the UI. That is the offline first pattern in
// full.
class Repository<T>(
private val store: Store<T>,
private val fetch: suspend () -> List<T>,
private val policy: RefreshPolicy,
) {
private val inFlight = AtomicBoolean(false)
// Reads never touch the network. Empty or stale is still something to
// show, and a blank loading screen over data already on the device is the
// failure this architecture exists to prevent.
fun current(): List<T> = store.read()
suspend fun refresh(trigger: Trigger, nowMillis: Long): Boolean {
if (!policy.shouldRefresh(store.lastFetchedAt, store.lastFailureAt, nowMillis, trigger)) return false
// The other half of the policy, and the half people leave out. On a
// cold start lastFetchedAt is zero, so six tabs opening in the same
// second all pass the check above and all six go to the network. One
// caller wins the flag and the rest return, and they still get the data
// the moment it lands, because every one of them is observing the store.
if (!inFlight.compareAndSet(false, true)) return false
return try {
store.write(fetch(), nowMillis)
true
} catch (e: CancellationException) {
// The screen closed while the request was in flight. That is not a
// failure, so it is rethrown rather than recorded. runCatching here
// would swallow it, put the repository into its failure cooloff,
// and quietly refuse the next screen open the user actually makes.
throw e
} catch (e: IOException) {
// A failed refresh does nothing to the UI on purpose. The screen
// already shows the last good data, so this belongs in a small
// could not refresh mark rather than a full screen error over
// content the user can perfectly well read.
store.recordFailure(nowMillis)
false
} finally {
inFlight.set(false)
}
}
}
com.androidinterview.offlinefirst.data.Store.kt
package com.androidinterview.offlinefirst.data
// The local source of truth. Room sits behind this, and observe is a Flow in
// the app, collected by the ViewModel. Collecting the Flow is what cancels it,
// so the collector's scope owns the unsubscribe.
//
// The inversion is the whole architecture. The UI subscribes once and renders
// again whenever the table changes, so it never asks whether what it sees came
// from a cache or a network call, and it never waits on a request to show
// something.
interface Store<T> {
fun observe(listener: (List<T>) -> Unit)
fun read(): List<T>
// The write carries the fetch time, because staleness is a property of the
// data and belongs beside it rather than in a preferences file that can
// drift out of step with the rows. A successful write also clears the
// failure time, because a cooloff should not outlive the failure.
fun write(values: List<T>, fetchedAt: Long)
val lastFetchedAt: Long
// The failure time is persisted for the same reason the fetch time is. Held
// in a field on the repository it is lost on process death, and surviving
// process death is the premise of the whole architecture.
fun recordFailure(atMillis: Long)
val lastFailureAt: Long
}
The write half, the outbox and its backoff, is written out under handling data syncing on an unstable network, and the conflict rule it needs is in the Google Notes answer. Implementing a caching mechanism asks the same question at the scale of one screen, one table and one staleness check. This answer is the architecture around that, the refresh policy, the retention policy, and a write path that survives the app being killed.
Tradeoffs I'd call out
- Room as source of truth vs a thinner in-memory cache. Making Room the actual source of truth, not just a cache the network falls back to, means every screen works offline for free and consistently. It also means every piece of remote data needs a table and a migration story, which is more upfront schema work than caching responses in memory.
- Optimistic writes vs waiting for server confirmation. Showing a pending action as done immediately makes the app feel fast and usable offline. It also means the UI can be showing something that later fails to sync, so it needs a clear way to surface that failure and let the user retry or discard it, rather than silently dropping it.
- Conflict resolution strategy. If two offline edits to the same record eventually sync from two devices, something has to decide the winner. Last-write-wins is the simplest and is fine for most consumer apps, but it silently discards one edit. Anything where losing an edit is unacceptable needs real merging, or the conflict surfaced to the user, which is meaningfully more work.
What breaks at scale, offline, and on a poor connection
- At scale, an unbounded local database is the quiet failure mode. A sync engine that only inserts and never expires old data eventually turns offline-first into an app that never frees storage, so a retention policy has to exist even though it is easy to skip in a first pass.
- Offline, this whole architecture is the point. The honest test is whether the app is fully usable in airplane mode for anything the user has previously loaded.
- On a poor connection, retry and backoff are what separate a resilient app from one that quietly stops syncing. A flaky connection should degrade to slower, less frequent syncs, not to an app that gives up after the first failure.
Watch