# Offline First Strategy

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

- Skill: `almasumdev/offline-first-strategy` (Agent Skill)
- Install (CLI): `npx skillmds@latest add almasumdev/offline-first-strategy`
- Raw SKILL.md: https://api.skillmd.com/api/skills/almasumdev/offline-first-strategy/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: almasumdev (https://skillmd.com/u/almasumdev)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/almasumdev/offline-first-strategy

---


# 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.

```kotlin
// Kotlin (Flow)
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)) }
}
```

```swift
// Swift
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 {
                // keep cached view; surface error only if nothing is cached
            }
            cont.finish()
        }
    }
}
```

```dart
// 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.

1. Write to the local store immediately with an `isPending` flag.
2. Render the UI from the local store; the change appears instantly.
3. Enqueue the write to an outbox.
4. A sync worker drains the outbox with retry/backoff.
5. 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

- [ ] Each data kind has a named authoritative source.
- [ ] Reads return cache first, then revalidate.
- [ ] Writes use an outbox with retry/backoff and a pending UI state.
- [ ] Cache invalidation policy is documented per entity.
- [ ] Secrets are in keychain/keystore, never in the main database.
- [ ] Storage has a cap and an eviction policy.
- [ ] Connectivity changes drive revalidation, not feature gating.
- [ ] Critical paths have offline integration tests.
- [ ] Offline indicator is present and non-blocking.

