Imagine: a user opens a mobile app on the subway, where the signal drops every 30 seconds. Without caching, every screen becomes an endless spinner, and on error — a blank void. The offline-first approach solves this: data loads once when connected and remains available locally. But cache isn't a magic wand: incorrect invalidation strategy leads to stale data, and disk overflow crashes the app. We've been designing multi-level caches for years, launched over 50 projects with offline-first architecture, and know the pitfalls. Our goal is to make users see data instantly and developers never wonder why the cache isn't updating.
Designing a Multi-Level Cache
A good cache architecture has several levels:
| Level | Storage | Speed | Lifetime | Tools |
|---|---|---|---|---|
| Memory | RAM | Instant | App restart | NSCache (iOS), LruCache (Android) |
| Disk | Files/DB | Fast | Hours–days | Room (Android), CoreData (iOS) |
| Network | Server | Slow | Forever (source of truth) | API with ETag |
We use the stale-while-revalidate strategy (cache-first, refresh-in-background): first display the cache instantly, then update data in the background. If data changed, the UI redraws. This gives the user a feeling of instant performance.
Choosing the right TTL is key. For API responses we set TTL from 5 to 30 minutes, for images up to 7 days. On unstable networks, we use ETag, saving up to 80% of traffic (average server cost savings up to 70%, e.g., $10k/month for high-traffic apps). Read time from Room is 1–3 ms, typical disk cache size is 10–50 MB.
On one project (a retail chain app), we implemented a three-level cache: list headers in LruCache (Android), details in Room with a 15-minute TTL, images in Coil with a 100 MB disk cache. Result: average screen load time dropped from 3 seconds to 200 ms, and server load fell by 65%. Contact us for a consultation to select optimal TTLs for your data.
| Data Type | Recommended TTL | Reason |
|---|---|---|
| Product list | 5–10 minutes | Frequently changes, freshness critical |
| Product images | 24 hours–7 days | Rarely change, large size |
| Configurations | 30 minutes | Rarely change, loaded on start |
Implementing Offline-First API Response Cache
On Android we use Room as a persistent cache with timestamps and ETags. Example:
// Entity with timestamp for invalidation @Entity(tableName = "products_cache") data class ProductCacheEntity( @PrimaryKey val id: String, val categoryId: String, val payload: String, // JSON string val cachedAt: Long, // Unix timestamp val etag: String? = null ) // Repository: cache-first logic class ProductRepository( private val api: ProductApi, private val dao: ProductCacheDao, private val cacheMaxAge: Long = 5 * 60 * 1000L // 5 minutes ) { fun getProductsByCategory(categoryId: String): Flow<List<Product>> = flow { // 1. Emit cached data immediately val cached = dao.getByCategory(categoryId) if (cached.isNotEmpty()) { emit(cached.map { it.toProduct() }) } // 2. Check freshness val oldestEntry = cached.minOfOrNull { it.cachedAt } ?: 0L val needsRefresh = System.currentTimeMillis() - oldestEntry > cacheMaxAge if (needsRefresh || cached.isEmpty()) { try { val fresh = api.getProducts(categoryId) val entities = fresh.map { it.toCacheEntity(categoryId) } dao.upsertAll(entities) emit(fresh) } catch (e: IOException) { // Network unavailable — cache already emitted, do nothing if (cached.isEmpty()) throw e // nothing to show — propagate } } } } UI subscribes to the Flow and receives data twice: first cache, then fresh. This is the basis of offline-first.
ETag for Traffic Savings
Instead of time-based invalidation, you can use HTTP ETag. The server returns ETag: "v42", the next request sends If-None-Match: "v42" — if data hasn't changed, server returns 304 with no body. Traffic savings can reach 80%.
OkHttp (HTTP client for Android and React Native) supports HTTP cache out of the box:
val cache = Cache( directory = File(context.cacheDir, "http-cache"), maxSize = 10L * 1024 * 1024 // 10 MB ) val client = OkHttpClient.Builder() .cache(cache) .build() On iOS, URLSession works similarly through URLCache. But HTTP caching only works with correct Cache-Control headers from the server.
Image Caching: Battle-Tested Libraries
For images we use proven solutions:
- Android: Coil — Kotlin-first, Compose-ready, memory + disk cache, placeholder/error states.
- iOS: Kingfisher or SDWebImage — async loading, NSCache + disk, progressive JPEG.
- React Native: react-native-fast-image (wrapper over SDWebImage/Glide).
// Coil in Compose AsyncImage( model = ImageRequest.Builder(context) .data(product.imageUrl) .memoryCacheKey(product.id) .diskCacheKey(product.imageUrl) .crossfade(true) .build(), contentDescription = product.title, placeholder = painterResource(R.drawable.placeholder), error = painterResource(R.drawable.error_image) ) Coil is on average 2x faster than Glide when loading images from cache (tested on Android 12). Kingfisher on iOS provides disk access times under 10 ms.
Cache Invalidation Strategies: Which to Choose?
Invalidation is the hardest part. We use a combination of approaches:
- TTL (time-to-live) — automatic expiry after a set interval. Simple, predictable, but data ages between TTL.
- Event-based — push notification from server on data change. Instant, but requires server support.
- Version-based — server sends
dataVersion, number comparison. Precise without data transfer. - Pull-to-refresh — user initiates update. Always needed as a fallback.
We combine TTL for basic data, event-based for critical data (e.g., account balance), and pull-to-refresh as a backup. Implementation takes 1–2 weeks depending on the number of data types.
How to Choose a Caching Strategy for Specific Data?
For frequently changing data (news feed), use TTL 5–10 minutes with event-based updates. For static reference data, versioning with a long TTL. Testing on real scenarios shows that the right combination of strategies improves user retention by 15–25%.
Steps to Implement Multi-Level Cache
- Audit current APIs and data.
- Design cache schema: choose layers, TTL for each type.
- Implement in-memory (NSCache/LruCache) and disk cache (Room/CoreData).
- Integrate HTTP cache and ETag.
- Set up invalidation (event-based + TTL).
- Test offline scenarios: network off, slow, drop.
- Documentation and team training.
Что входит в работу
- Audit of current APIs and data
- Design of cache architecture (layer schema, TTL selection)
- Implementation of in-memory and disk cache
- Integration of HTTP cache and ETag
- Configuration of invalidation (event-based + TTL)
- Testing offline scenarios (network off, slow, drop)
- Documentation and team training
- Support during App Store / Google Play release
Our team has developed over 50 mobile apps with offline-first architecture. We guarantee stable cache performance in unstable network conditions. All solutions undergo code review and load testing. Contact us for a project evaluation — we'll propose an optimal caching scheme for your data.







