Effective caching is essential for mobile applications to provide fast, responsive experiences while minimizing network usage and battery consumption. Combined with proper API performance testing, caching strategies form the foundation of mobile app performance optimization. This guide covers caching strategies, implementation patterns, and testing approaches.

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.