| name | offline-first-strategy |
| description | Designing a mobile app as offline-first - caching tiers, authoritative sources, read and write paths, and the boundaries between device and server. Use when a feature needs to work without connectivity or when diagnosing stale/inconsistent data. |
Offline-First Strategy
Instructions
Mobile devices are flaky by nature. Treat the network as optional. Offline-first apps feel fast, survive tunnels and airplane mode, and reduce server load. The trick is picking the authoritative source per data kind and building a predictable read/write path around it.
1. Tiers of Cache
| Tier | Where | Lifespan | Example |
|---|
| L0: in-memory | app process | foreground session | current screen view model |
| L1: on-disk structured | SQLite, Realm, Core Data, Room, Drift, SQLDelight | persistent | lists, entities, search indices |
| L2: on-disk blob | file cache directory | purgeable | images, videos, exports |
| L3: secure | Keychain / Keystore | persistent, encrypted | tokens, keys |
| Remote | server | authoritative for shared data | transactional writes |
Pick tiers explicitly per data type; do not let a JSON in SharedPreferences grow into a database.
2. Authoritative Source Rule
For every data kind, declare one authoritative source:
- User-owned local data (notes draft, reading position, settings): device is authoritative, server is backup.
- Shared collaborative data (chat messages, shared docs): server is authoritative, device is cache.
- Reference data (catalog, pricing): server authoritative, device caches with TTL.
- Secrets (tokens): keychain authoritative, memory is ephemeral.
Writing this down prevents "whose value wins" debates during conflict resolution.
3. Read Path
UI -> Repository.read(id) -> emits Loading -> emits Cached (fast) -> refresh if stale -> emits Fresh
- Return cached data immediately on subscribe.
- Fire a revalidation in parallel if the cache is stale.
- Emit new state when fresh data arrives. Never block the UI on the network when a cache exists.
fun observeArticle(id: String): Flow<Resource<Article>> = flow {
val cached = dao.get(id)
if (cached != null) emit(Resource.Success(cached))
runCatching { api.article(id) }
.onSuccess { fresh -> dao.upsert(fresh); emit(Resource.Success(fresh)) }
.onFailure { if (cached == null) emit(Resource.Error(it)) }
}
func observeArticle(id: String) -> AsyncStream<Resource<Article>> {
AsyncStream { cont in
Task {
if let cached = try? await store.get(id) { cont.yield(.success(cached)) }
do {
let fresh = try await api.article(id)
try await store.upsert(fresh)
cont.yield(.success(fresh))
} catch {
}
cont.finish()
}
}
}
// Dart
Stream<Resource<Article>> observeArticle(String id) async* {
final cached = await dao.get(id);
if (cached != null) yield Resource.success(cached);
try {
final fresh = await api.article(id);
await dao.upsert(fresh);
yield Resource.success(fresh);
} catch (e) {
if (cached == null) yield Resource.error(e);
}
}
4. Write Path
Writes should never block the UI on the network.
- Write to the local store immediately with an
isPending flag.
- Render the UI from the local store; the change appears instantly.
- Enqueue the write to an outbox.
- A sync worker drains the outbox with retry/backoff.
- On ack, clear the pending flag; on permanent failure, mark conflict and surface it.
This is the outbox pattern and it is the single biggest win for perceived performance.
5. Cache Invalidation
Choose an invalidation strategy per data kind:
- TTL: staleAfter + hardExpireAfter. Simplest.
- ETag / Last-Modified: server-led freshness; supports 304 responses.
- Subscription / push: server tells you when to invalidate (websocket, SSE, silent push).
- Versioned bundles: for reference data, bump a version key and purge.
Document the policy per entity.
6. Network Awareness
- Detect connectivity changes but do not use them as the only signal. A reachable API may still fail.
- On regaining connectivity, drain the outbox and revalidate visible screens only (not the whole cache).
- Surface a subtle offline indicator; avoid full-screen blockers unless the user explicitly chose an online-only feature.
7. Storage Budgets
- Set a cap (for example 200 MB) and evict LRU when exceeded.
- Keep sensitive data out of L2 blob caches.
- Never store unbounded history on-device.
- Provide a "clear cache" action somewhere in settings for support.
8. Testing Offline
- Unit test the repository with a fake remote that can be in three states: ok, slow, failing.
- UI tests toggle airplane mode via
adb or xcrun simctl status_bar override.
- Seed a realistic cache on device before running offline flows.
- Track a CI job that runs the critical-path suite with network disabled.
9. Anti-Patterns
- Writing to the network first and then to the cache. The UI is sluggish and inconsistent.
- Storing derived views on disk (search results, sorted lists). Store facts; derive on read.
- Global caches with no eviction policy.
- Silent failures when offline. Surface the pending state in the UI.
- Shipping features that only work online without saying so.
Checklist