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
- 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.