Phil Karlton's quip — "there are only two hard things in computer science: cache invalidation and naming things" — is a joke, but the hard part is real. Choosing the wrong invalidation strategy is one of the most common sources of stale-data bugs in Android apps.
The Core Trade-off
Every caching strategy sits on a spectrum between freshness and efficiency:
| Strategy | Network calls | Data freshness |
|---|---|---|
| TTL (time-based) | Periodic | Eventually fresh after TTL |
| Event-driven | On change only | Near-instant |
| Polling | Constant | Near-realtime |
| Manual (pull-to-refresh) | User-initiated | On demand |
| Hybrid | Combined | Configurable |
1. TTL-Based Invalidation
The simplest strategy: cache entries expire after a fixed time.
data class CachedValue<T>(
val data: T,
val expiresAt: Long
) {
val isExpired: Boolean get() = System.currentTimeMillis() > expiresAt
companion object {
fun <T> fresh(data: T, ttlMs: Long) = CachedValue(
data = data,
expiresAt = System.currentTimeMillis() + ttlMs
)
}
}
When to use: Read-heavy data where slight staleness is acceptable (article lists, product catalogs, public profiles).
Pitfall: If users make writes, TTL doesn't invalidate immediately — they may see their own edits disappear (the so-called "write-then-read" stale cache bug).
2. Write-Through Invalidation
Update or invalidate the cache immediately on every write.
class ArticleRepository(private val dao: ArticleDao, private val api: ArticleApi) {
suspend fun updateArticle(article: Article) {
// Write to server
val updated = api.updateArticle(article)
// Write-through: update cache immediately
dao.insert(updated.toEntity())
}
suspend fun deleteArticle(id: String) {
api.deleteArticle(id)
// Invalidate by removing from cache
dao.deleteById(id)
}
}
When to use: Any time the user is the author of the data. Ensures they always see their own writes.
3. Event-Driven Invalidation (Push)
The server signals when data changes (WebSocket, FCM). The client invalidates on signal.
// Receive a push notification: "article_updated", id=42
class MessageHandler(private val repository: ArticleRepository) {
suspend fun handleMessage(message: RemoteMessage) {
val type = message.data["type"] ?: return
val id = message.data["id"] ?: return
when (type) {
"article_updated" -> repository.invalidateAndRefresh(id)
"article_deleted" -> repository.evict(id)
}
}
}
// In repository:
suspend fun invalidateAndRefresh(id: String) {
dao.deleteById(id)
val fresh = api.getArticle(id)
dao.insert(fresh.toEntity())
}
When to use: Collaborative apps, real-time feeds, chat, anything where multiple users modify shared data.
4. Version/ETag-Based Invalidation
The server includes a version number or hash. Stale data is detected by comparing versions, not time.
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
val etag: String, // server-provided version hash
val serverVersion: Long // monotonic version counter
)
// Check if cache is stale by comparing etag
suspend fun getArticle(id: String): Article {
val cached = dao.getById(id)
val serverMeta = api.getArticleMeta(id) // lightweight HEAD-like call
return if (cached?.etag == serverMeta.etag) {
cached.toDomain() // same etag = no change
} else {
val fresh = api.getArticle(id)
dao.insert(fresh.toEntity())
fresh
}
}
5. Hybrid Strategy (Production-Recommended)
Combine TTL for background refresh + event-driven for critical data + write-through for user writes:
fun getArticle(id: String): Flow<Article> = flow {
val cached = dao.getById(id)
// 1. Serve stale immediately (never show blank screen)
cached?.let { emit(it.toDomain()) }
// 2. Refresh if expired (TTL)
if (cached == null || cached.isExpired) {
val fresh = api.getArticle(id)
dao.insert(fresh.toEntity())
emit(fresh)
}
}
// 3. Push invalidation (FCM handler calls invalidateAndRefresh when signaled)
Key Takeaways
| Strategy | Best for |
|---|---|
| TTL | Read-heavy, eventually-consistent data |
| Write-through | User's own writes; own-content consistency |
| Event-driven (FCM) | Collaborative/shared data |
| ETag/version | When bandwidth is precious or staleness detection needs precision |
| Hybrid | Production apps — layer strategies by data type |