androidengineers.Book a session

Caching Strategies

Cache Invalidation Strategies

article20 minHard

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:

StrategyNetwork callsData freshness
TTL (time-based)PeriodicEventually fresh after TTL
Event-drivenOn change onlyNear-instant
PollingConstantNear-realtime
Manual (pull-to-refresh)User-initiatedOn demand
HybridCombinedConfigurable

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

StrategyBest for
TTLRead-heavy, eventually-consistent data
Write-throughUser's own writes; own-content consistency
Event-driven (FCM)Collaborative/shared data
ETag/versionWhen bandwidth is precious or staleness detection needs precision
HybridProduction apps — layer strategies by data type

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Cache Invalidation Strategies | Android System Design | Android Engineers