androidengineers.Book a session

Caching Strategies

Room as Cache: Keys & TTL

article20 minMedium

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

ConceptRule
cachedAt columnAdd to every cached entity; use epoch milliseconds
TTL checkCompare System.currentTimeMillis() - cachedAt against your TTL
Stale-while-revalidateEmit stale data immediately, then emit fresh after fetch
PruningPeriodic WorkManager job beats per-read deletion
Per-type TTLMetadata table gives each cache key its own expiration

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Room as Cache: Keys & TTL | Android System Design | Android Engineers