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 TypeStrategyTTLInvalidation Trigger
User profileCache-first24 hoursUser edits profile, logout
Product catalogNetwork-first with fallback1 hourAdmin updates, daily sync
Shopping cartNetwork-firstNo cacheEvery modification
Static assets (images)Cache-first7 daysApp version update
Real-time feedsNetwork-first5 minutesPull-to-refresh, push notification
Configuration/feature flagsCache-first1 hourApp 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

  1. Use appropriate cache durations - Short for dynamic data, long for static
  2. Implement cache invalidation - Clear cache on user logout, data updates
  3. Monitor cache size - Prevent unlimited growth
  4. Test offline scenarios - Ensure graceful degradation
  5. Use ETags and conditional requests - Optimize bandwidth
  6. Cache at multiple layers - HTTP cache + database cache
  7. 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