| name | caching-strategies |
| description | Apply read-through, write-through, TTL, LRU, and encrypted caches correctly across memory, disk, and secure storage on mobile. |
Caching Strategies
Instructions
Every cache is a tradeoff between freshness, memory, disk, and security. Pick the strategy to match the data. Mis-sized or mis-policied caches cause jank (evicting too aggressively), memory kills (unbounded growth), or stale UI (never revalidating).
1. Strategy Catalog
| Strategy | When to use |
|---|
| Read-through | Default for feed/list screens. Cache sits in front of origin. |
| Write-through | Writes go to cache + origin synchronously. Safe but slow. |
| Write-behind | Write cache immediately; persist to origin async. Fast but risky. |
| Cache-aside | App checks cache; on miss, fetches and populates. Most flexible. |
| Stale-while-revalidate | Serve cached while refreshing in background. |
| TTL (time-to-live) | Expire entries after N seconds. Easy to reason about. |
| LRU (least recently used) | Bounded by size; evicts coldest. Best for memory caches. |
| LFU (least frequently used) | Retains hot items. Better for mixed workloads. |
| Keyset / content-addressable | Immutable blobs keyed by hash (e.g., images). |
2. Sizing
| Layer | Size budget (typical) |
|---|
| JSON/memory cache | 4–16 MB, LRU by entry size |
| Image memory cache | 20–25% of process heap |
| HTTP disk cache (OkHttp/URLCache) | 20–50 MB for API JSON |
| Image disk cache | 128–256 MB |
| SQLite/Drift/Realm | Product-driven; cap at 200 MB and evict LRU rows |
3. Read-Through + Stale-While-Revalidate
Most data is "it's fine if it's 60 seconds old." Render cache, refresh in background, diff in.
Kotlin:
class FeedRepository(private val api: Api, private val cache: Cache<String, Feed>) {
fun feed(userId: String): Flow<Feed> = flow {
cache.get(userId)?.let { emit(it) }
try {
val fresh = api.feed(userId)
cache.put(userId, fresh)
emit(fresh)
} catch (e: IOException) { }
}
}
Swift:
actor FeedRepository {
private let api: Api
private var cache: [String: Feed] = [:]
func feed(for userId: String) -> AsyncStream<Feed> {
AsyncStream { cont in
Task {
if let c = cache[userId] { cont.yield(c) }
if let fresh = try? await api.feed(userId) {
cache[userId] = fresh
cont.yield(fresh)
}
cont.finish()
}
}
}
}
4. TTL and LRU
Pure TTL is easy but can cause all entries to expire simultaneously. Combine with an LRU that tracks size.
Kotlin using Caffeine:
val cache: Cache<String, Feed> = Caffeine.newBuilder()
.maximumWeight(8L * 1024 * 1024)
.weigher<String, Feed> { _, v -> v.byteSize }
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build()
Swift NSCache is LRU with size limits:
let cache = NSCache<NSString, Feed>()
cache.totalCostLimit = 8 * 1024 * 1024
cache.setObject(feed, forKey: userId as NSString, cost: feed.byteSize)
5. Persistence Layer
For true offline, persist in a local DB or a typed disk cache:
- Drift (Flutter) / Isar / Realm — typed, queryable, indexed.
- Room (Android) — first-party, coroutines/Flow support.
- Core Data / SwiftData (iOS) or GRDB / Realm.
- WatermelonDB / Realm / MMKV for React Native.
Rule: the DB is the source of truth. Network writes into the DB; UI reads from the DB reactively. This eliminates two-source-of-truth bugs.
6. Encrypted Caches
For anything that touches PII, health, financial, or auth:
| Platform | Storage |
|---|
| iOS | Keychain for secrets; file protection class completeUntilFirstUserAuthentication for PII. |
| Android | EncryptedSharedPreferences / EncryptedFile + Keystore |
| Flutter | flutter_secure_storage (Keychain/Keystore under the hood) |
| RN | react-native-keychain, expo-secure-store |
Android Jetpack Security example:
val masterKey = MasterKey.Builder(ctx)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val prefs = EncryptedSharedPreferences.create(
ctx, "secure_prefs", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
iOS Keychain (simple wrapper):
func set(_ value: String, key: String) throws {
let q: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecValueData: Data(value.utf8),
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
SecItemDelete(q as CFDictionary)
let status = SecItemAdd(q as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError(status) }
}
Rules:
- Never cache bearer tokens in
UserDefaults / SharedPreferences / AsyncStorage.
- Tie sensitive cache lifetime to auth session.
- On logout, zero the cache (
cache.invalidateAll(), delete encrypted files).
7. Invalidation
Surgical invalidation beats blanket invalidation.
- Tag cache entries by domain (
user:123, feed:home). On mutation, invalidate only matching tags.
- react-query:
queryClient.invalidateQueries({ queryKey: ['feed'] }).
- Paging 3:
pagingSource.invalidate().
8. Metrics
Every cache should expose:
- Hit rate (hits / (hits + misses)).
- Eviction rate.
- Byte size.
- p95
get / put latency.
Log these daily. A hit rate trending down is the early signal of a cache key bug.
9. Anti-patterns
- Unbounded caches — always cap by size or count.
- Caching everything — auth responses, one-time requests, huge binaries: usually not worth it.
- Cache objects that reference a Context/View — guaranteed leak.
- Shared cache across users on the same device — data leakage after account switch; partition by user id.
Checklist