TL;DR
- Choose cache-first for read-heavy data (user profiles, catalogs) and network-first for time-sensitive content (feeds, notifications)
- Implement multi-layer caching: HTTP cache (OkHttp) for network layer + Room/SQLite for persistence + in-memory LRU for hot data
- Test offline scenarios systematically—cache hit/miss, expiration, invalidation, and storage limits under real network conditions
Best for: Teams building mobile apps with offline requirements or unreliable network conditions Skip if: Your app is purely online with no offline functionality needs Read time: 15 minutes
Effective caching is essential for mobile applications to provide fast, responsive experiences while minimizing network usage and battery consumption. This guide covers caching strategies, implementation patterns, and testing approaches with practical code examples.
Combined with proper API performance testing, caching strategies form the foundation of mobile app performance optimization. Understanding mobile app performance metrics helps you measure the impact of your caching implementation.
AI-Assisted Approaches
Modern AI assistants can help design and troubleshoot caching implementations. Here are practical prompts for common tasks:
Designing cache invalidation logic:
My mobile app caches user profiles, product catalogs, and shopping cart data.
Design an invalidation strategy for each data type considering:
1. How often the data changes (profiles: rarely, catalogs: daily, cart: frequently)
2. User actions that should trigger invalidation (logout, purchase, refresh pull)
3. Server-push scenarios (price changes, inventory updates)
4. Conflict resolution when offline changes sync
Provide Kotlin code using Room database with proper cache timestamps.
Troubleshooting cache inconsistencies:
Users report seeing stale data after app updates. Current setup:
- OkHttp HTTP cache (10MB)
- Room database for offline storage
- SharedPreferences for user settings
The issue: After updating a profile via API, the old profile shows
until app restart. Provide debugging steps and code fixes.
Current repository code:
[paste your repository implementation]
Optimizing cache size management:
My mobile app's cache grows unbounded and users complain about storage.
Current cache layers:
- Image cache: Glide with 250MB disk cache
- API responses: Room database (currently 150MB)
- HTTP cache: OkHttp 50MB
Design a cache eviction strategy that:
1. Prioritizes recently accessed data
2. Distinguishes between critical (user data) and expendable (images) cache
3. Provides user controls for clearing cache
4. Monitors and reports cache sizes
Include Android implementation with WorkManager for periodic cleanup.
Generating cache test scenarios:
Generate comprehensive test cases for a mobile caching layer that includes:
- HTTP cache with OkHttp
- Room database for offline persistence
- In-memory LRU cache for hot data
Test scenarios should cover:
1. Cache hit/miss paths
2. Expiration and TTL handling
3. Offline mode with stale data
4. Cache invalidation on user actions
5. Storage limit enforcement
6. Concurrent access patterns
Provide Kotlin test code using MockK and Turbine for Flow testing.
When to Use Different Caching Strategies
Strategy Decision Framework
| Data Type | Strategy | TTL | Invalidation Trigger |
|---|---|---|---|
| User profile | Cache-first | 24 hours | User edits profile, logout |
| Product catalog | Network-first with fallback | 1 hour | Admin updates, daily sync |
| Shopping cart | Network-first | No cache | Every modification |
| Static assets (images) | Cache-first | 7 days | App version update |
| Real-time feeds | Network-first | 5 minutes | Pull-to-refresh, push notification |
| Configuration/feature flags | Cache-first | 1 hour | App launch, background sync |
Consider Cache-First When
- Data changes infrequently: User profiles, app settings, reference data
- Offline access is critical: Documents, downloaded content, user-generated drafts
- Network latency impacts UX: Initial app load, navigation transitions
- Bandwidth is expensive: Users on limited data plans, emerging markets
Consider Network-First When
- Data freshness is critical: Financial data, inventory levels, real-time collaboration
- Security requirements mandate it: Authentication tokens, sensitive data
- Data changes unpredictably: Social feeds, notifications, collaborative documents
- Server is the source of truth: Shopping cart total, order status
Why Caching Matters
Benefits of proper caching:
- Faster load times: Instant data access from local storage
- Reduced network usage: Lower data costs for users
- Offline support: App functionality without connectivity
- Battery optimization: Fewer network requests
- Better UX: Seamless experience during poor connectivity
Android Caching Strategies
HTTP Cache with OkHttp
val cacheSize = 10 * 1024 * 1024 // 10 MB
val cache = Cache(context.cacheDir, cacheSize.toLong())
val client = OkHttpClient.Builder()
.cache(cache)
.build()
// Configure cache headers on server:
// Cache-Control: max-age=3600, public
// Expires: Wed, 21 Oct 2024 07:28:00 GMT
// ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Custom Cache Implementation
class ApiCache(private val context: Context) {
private val prefs = context.getSharedPreferences("api_cache", Context.MODE_PRIVATE)
fun <T> getCached(key: String, type: Class<T>, maxAge: Long = 3600_000): T? {
val cachedJson = prefs.getString(key, null) ?: return null
val timestamp = prefs.getLong("${key}_timestamp", 0)
if (System.currentTimeMillis() - timestamp > maxAge) {
// Cache expired
return null
}
return Gson().fromJson(cachedJson, type)
}
fun <T> cache(key: String, data: T) {
val json = Gson().toJson(data)
prefs.edit()
.putString(key, json)
.putLong("${key}_timestamp", System.currentTimeMillis())
.apply()
}
fun invalidate(key: String) {
prefs.edit()
.remove(key)
.remove("${key}_timestamp")
.apply()
}
}
// Usage
class UserRepository(private val api: ApiService, private val cache: ApiCache) {
suspend fun getUser(id: String): User {
// Try cache first
cache.getCached("user_$id", User::class.java)?.let { return it }
// Fetch from network
val user = api.getUser(id)
// Cache result
cache.cache("user_$id", user)
return user
}
}
Room Database Cache
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: String,
val name: String,
val email: String,
val cachedAt: Long = System.currentTimeMillis()
)
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :userId AND cachedAt > :minTimestamp")
suspend fun getCachedUser(userId: String, minTimestamp: Long): UserEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun cacheUser(user: UserEntity)
@Query("DELETE FROM users WHERE cachedAt < :timestamp")
suspend fun clearOldCache(timestamp: Long)
}
class UserRepository(
private val api: ApiService,
private val userDao: UserDao
) {
suspend fun getUser(id: String, maxAge: Long = 3600_000): User {
val minTimestamp = System.currentTimeMillis() - maxAge
// Check cache
userDao.getCachedUser(id, minTimestamp)?.let {
return it.toUser()
}
// Fetch from API
val user = api.getUser(id)
// Update cache
userDao.cacheUser(user.toEntity())
return user
}
}
Testing Cache Strategies
Comprehensive testing of cache behavior is essential. For broader API testing techniques, explore API testing mastery best practices that complement cache validation.
Testing Cache Hit/Miss
class CacheTest {
@Test
fun testCacheHit() = runTest {
val cache = ApiCache(context)
val repository = UserRepository(mockApi, cache)
// First call - should hit API
val user1 = repository.getUser("123")
verify(exactly = 1) { mockApi.getUser("123") }
// Second call - should hit cache
val user2 = repository.getUser("123")
// API should not be called again
verify(exactly = 1) { mockApi.getUser("123") }
assertEquals(user1, user2)
}
@Test
fun testCacheExpiration() = runTest {
val cache = ApiCache(context)
val repository = UserRepository(mockApi, cache)
// First call
repository.getUser("123")
// Wait for cache to expire
delay(3600_001) // Just over 1 hour
// Second call - cache expired, should hit API again
repository.getUser("123")
verify(exactly = 2) { mockApi.getUser("123") }
}
@Test
fun testCacheInvalidation() = runTest {
val cache = ApiCache(context)
val repository = UserRepository(mockApi, cache)
repository.getUser("123")
// Invalidate cache
cache.invalidate("user_123")
// Should hit API again
repository.getUser("123")
verify(exactly = 2) { mockApi.getUser("123") }
}
}
Testing Offline Behavior
Testing offline scenarios is critical for mobile apps. Learn more about comprehensive network condition testing strategies to ensure your cache works reliably across all connectivity states.
@Test
fun testOfflineCache() = runTest {
val cache = ApiCache(context)
// Populate cache while online
networkMonitor.setConnected(true)
val user = repository.getUser("123")
assertNotNull(user)
// Go offline
networkMonitor.setConnected(false)
// Configure API to throw exception when offline
every { mockApi.getUser(any()) } throws UnknownHostException()
// Should still return cached data
val cachedUser = repository.getUser("123")
assertNotNull(cachedUser)
assertEquals(user, cachedUser)
}
Cache Policies
Time-Based Expiration
enum class CachePolicy(val maxAge: Long) {
SHORT(5 * 60 * 1000), // 5 minutes
MEDIUM(30 * 60 * 1000), // 30 minutes
LONG(24 * 60 * 60 * 1000), // 24 hours
PERMANENT(Long.MAX_VALUE) // Never expires
}
class CacheManager {
fun <T> get(key: String, policy: CachePolicy, fetch: suspend () -> T): T {
val cached = cache.getCached(key, maxAge = policy.maxAge)
if (cached != null) return cached
val fresh = fetch()
cache.cache(key, fresh)
return fresh
}
}
// Usage
val user = cacheManager.get("user_123", CachePolicy.MEDIUM) {
api.getUser("123")
}
Network-First vs Cache-First
class CacheStrategy {
// Network-first: Always try network, fall back to cache
suspend fun <T> networkFirst(
key: String,
fetch: suspend () -> T
): T {
return try {
val result = fetch()
cache.cache(key, result)
result
} catch (e: IOException) {
cache.getCached(key) ?: throw e
}
}
// Cache-first: Use cache if available, update in background
suspend fun <T> cacheFirst(
key: String,
fetch: suspend () -> T
): T {
val cached = cache.getCached(key)
// Return cached immediately if available
if (cached != null) {
// Update cache in background
CoroutineScope(Dispatchers.IO).launch {
try {
val fresh = fetch()
cache.cache(key, fresh)
} catch (e: Exception) {
// Silently fail background refresh
}
}
return cached
}
// No cache, fetch from network
val result = fetch()
cache.cache(key, result)
return result
}
}
@Test
fun testNetworkFirst() = runTest {
val strategy = CacheStrategy()
// Network available - should fetch from network
val user1 = strategy.networkFirst("user_123") {
mockApi.getUser("123")
}
verify { mockApi.getUser("123") }
// Network unavailable - should use cache
every { mockApi.getUser(any()) } throws IOException()
val user2 = strategy.networkFirst("user_123") {
mockApi.getUser("123")
}
assertEquals(user1, user2)
}
Cache Synchronization
Sync Manager
class SyncManager(
private val api: ApiService,
private val database: AppDatabase
) {
suspend fun syncUsers() {
val users = api.getUsers()
database.withTransaction {
// Clear old data
database.userDao().deleteAll()
// Insert fresh data
database.userDao().insertAll(users.map { it.toEntity() })
// Update sync timestamp
setSyncTimestamp("users", System.currentTimeMillis())
}
}
fun needsSync(key: String, maxAge: Long = 3600_000): Boolean {
val lastSync = getSyncTimestamp(key)
return System.currentTimeMillis() - lastSync > maxAge
}
}
@Test
fun testSyncManager() = runTest {
val syncManager = SyncManager(mockApi, database)
// Should need sync initially
assertTrue(syncManager.needsSync("users"))
// Perform sync
mockApi.respondWith(listOf(User("1", "John"), User("2", "Jane")))
syncManager.syncUsers()
// Should not need sync immediately after
assertFalse(syncManager.needsSync("users"))
// Should need sync after expiration
delay(3600_001)
assertTrue(syncManager.needsSync("users", maxAge = 3600_000))
}
Storage Management
Cache Size Limits
class CacheSizeManager(private val maxSize: Long = 50 * 1024 * 1024) { // 50 MB
fun enforceLimit() {
val cacheDir = context.cacheDir
val currentSize = calculateSize(cacheDir)
if (currentSize > maxSize) {
val filesToDelete = getOldestFiles(cacheDir, currentSize - maxSize)
filesToDelete.forEach { it.delete() }
}
}
private fun calculateSize(dir: File): Long {
return dir.listFiles()?.sumOf { file ->
if (file.isDirectory) calculateSize(file) else file.length()
} ?: 0
}
private fun getOldestFiles(dir: File, bytesToFree: Long): List<File> {
val files = dir.listFiles()?.sortedBy { it.lastModified() } ?: emptyList()
var freedBytes = 0L
val toDelete = mutableListOf<File>()
for (file in files) {
if (freedBytes >= bytesToFree) break
toDelete.add(file)
freedBytes += file.length()
}
return toDelete
}
}
@Test
fun testCacheSizeEnforcement() {
val manager = CacheSizeManager(maxSize = 1024) // 1 KB limit
// Create files exceeding limit
createFile("file1.txt", 512)
createFile("file2.txt", 512)
createFile("file3.txt", 512) // Total: 1536 bytes
manager.enforceLimit()
// Oldest file should be deleted
assertFalse(File("file1.txt").exists())
assertTrue(File("file2.txt").exists())
assertTrue(File("file3.txt").exists())
}
Best Practices
- Use appropriate cache durations - Short for dynamic data, long for static
- Implement cache invalidation - Clear cache on user logout, data updates
- Monitor cache size - Prevent unlimited growth
- Test offline scenarios - Ensure graceful degradation
- Use ETags and conditional requests - Optimize bandwidth
- Cache at multiple layers - HTTP cache + database cache
- Implement cache warming - Preload critical data
Conclusion
Effective API caching requires:
- Strategic cache policies (time-based, network conditions)
- Proper invalidation mechanisms
- Storage management
- Offline support
- Comprehensive testing
Well-implemented caching significantly improves mobile app performance, reduces costs, and enhances user experience across all network conditions.
Official Resources
See Also
- Mobile App Performance Testing - Metrics and tools to measure cache effectiveness
- Cross-Platform Mobile Testing - Strategies for testing cache behavior across iOS and Android
- API Testing Mastery - Test cache behavior as part of API validation
- API Performance Testing - Validate API response times with and without caching
- Test Data Management - Strategies for managing test data in cached environments
