Using Room as a structured local cache gives you persistent, queryable storage with TTL (time-to-live) invalidation, typed access, and reactive updates via Flow — far more powerful than raw disk files for structured data.
TTL Metadata Pattern
Embed a cachedAt timestamp in every cached entity so you can check freshness without hitting the network:
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
val body: String,
val authorId: String,
val cachedAt: Long = System.currentTimeMillis()
)
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles WHERE id = :id")
suspend fun getById(id: String): ArticleEntity?
@Query("SELECT * FROM articles ORDER BY cachedAt DESC")
fun getAllAsFlow(): Flow<List<ArticleEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(article: ArticleEntity)
@Query("DELETE FROM articles WHERE cachedAt < :cutoff")
suspend fun deleteExpired(cutoff: Long)
@Query("DELETE FROM articles WHERE id = :id")
suspend fun deleteById(id: String)
}
Cache Repository with TTL
class ArticleCacheRepository(
private val dao: ArticleDao,
private val api: ArticleApi,
private val ttlMs: Long = 15 * 60 * 1000L // 15 minutes
) {
fun getArticle(id: String): Flow<ArticleEntity?> = flow {
val cached = dao.getById(id)
val isExpired = cached == null ||
System.currentTimeMillis() - cached.cachedAt > ttlMs
if (!isExpired) {
emit(cached)
return@flow
}
// Cache miss or expired — fetch from network
emit(cached) // emit stale data first so UI isn't blank
try {
val fresh = api.getArticle(id)
val entity = ArticleEntity(
id = fresh.id,
title = fresh.title,
body = fresh.body,
authorId = fresh.authorId
)
dao.insert(entity)
emit(entity)
} catch (e: IOException) {
// Leave stale data in UI if network fails
}
}
suspend fun pruneExpired() {
val cutoff = System.currentTimeMillis() - ttlMs
dao.deleteExpired(cutoff)
}
}
Scheduled Cache Pruning with WorkManager
Don't delete stale entries on every read — it's wasteful. Instead, schedule periodic pruning:
class CachePruneWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val repo = ArticleCacheRepository(
dao = AppDatabase.getInstance(applicationContext).articleDao(),
api = RetrofitClient.articleApi
)
repo.pruneExpired()
return Result.success()
}
}
// Schedule in Application.onCreate
val pruneRequest = PeriodicWorkRequestBuilder<CachePruneWorker>(1, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"cache_prune",
ExistingPeriodicWorkPolicy.KEEP,
pruneRequest
)
Per-Key TTL with a Metadata Table
When different entity types need different TTLs, use a separate metadata table:
@Entity(tableName = "cache_metadata")
data class CacheMetadata(
@PrimaryKey val key: String, // e.g. "articles_list", "user_profile_42"
val cachedAt: Long,
val ttlMs: Long
)
@Dao
interface CacheMetadataDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun put(meta: CacheMetadata)
@Query("SELECT * FROM cache_metadata WHERE key = :key")
suspend fun get(key: String): CacheMetadata?
@Query("DELETE FROM cache_metadata WHERE cachedAt + ttlMs < :now")
suspend fun deleteExpired(now: Long = System.currentTimeMillis())
}
Key Takeaways
| Concept | Rule |
|---|---|
cachedAt column | Add to every cached entity; use epoch milliseconds |
| TTL check | Compare System.currentTimeMillis() - cachedAt against your TTL |
| Stale-while-revalidate | Emit stale data immediately, then emit fresh after fetch |
| Pruning | Periodic WorkManager job beats per-read deletion |
| Per-type TTL | Metadata table gives each cache key its own expiration |