Caching Part 5: Invalidation — The Hard Problem Nobody Solves Well
Caching Deep Dive(9-part series)
- P3Caching Part 3: Strategies — Read-Through, Write-Through, and Beyond
- P4Caching Part 4: Eviction Policies — LRU, LFU, and Making Room
- P5Caching Part 5: Invalidation — The Hard Problem Nobody Solves Well(here)
- P6Caching Part 6: When It Goes Wrong — Thundering Herds, Hot Keys, and Nightmares
- P7Caching Part 7: In Production — Monitoring, Sizing, and Staying Alive

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Every caching decision you make works great until the data changes. Then you have a choice: serve stale data, or figure out how to invalidate. This post is about the strategies for the second option.
The Problem
The database has the truth. The cache has a copy. When the truth changes, the copy is wrong. How do you fix it?
Strategy 1: TTL-Based Expiration
The simplest approach: set a time-to-live on every cache entry. When it expires, the next request fetches fresh data.
// Spring Cache with TTL
@Cacheable(value = ["products"], key = "#id") // TTL configured in Redis
fun getProduct(id: Long): Product {
return repository.findById(id).orElseThrow()
}# application.yml - Spring Cache TTL
spring:
cache:
type: redis
redis:
time-to-live: 10m # 10 minutesPros: Zero complexity. Works everywhere. Cons: Data can be stale for up to the TTL duration. You're choosing between freshness and performance.
Good for: Data where "eventually fresh" is acceptable — product descriptions, blog content, user profiles. Not for: inventory counts, account balances.
Strategy 2: Explicit Invalidation on Write
When the database changes, actively delete the corresponding cache entry.
@Service
class ProductService(
private val repository: ProductRepository
) {
@Cacheable(value = ["products"], key = "#id")
fun getProduct(id: Long): Product {
return repository.findById(id).orElseThrow()
}
@CacheEvict(value = ["products"], key = "#result.id")
fun updateProduct(id: Long, request: UpdateProductRequest): Product {
return repository.save(request.toEntity(id))
}
@CacheEvict(value = ["products"], key = "#id")
fun deleteProduct(id: Long) {
repository.deleteById(id)
}
}Pros: Immediate consistency. Cache is always fresh. Cons: Every write now involves a cache operation too. And it only works if all write paths go through your service.
Watch out for direct DB updates. If someone runs a SQL update directly on the database, the cache won't know. Your invalidation is only as good as your write path coverage.
Strategy 3: Event-Driven Invalidation
Instead of coupling cache invalidation to your write path, listen for change events. This is essential in microservice architectures.
Option A: Application-Level Events
@Component
class CacheInvalidationListener(
private val cacheManager: CacheManager
) {
@EventListener
fun onProductUpdated(event: ProductUpdatedEvent) {
val cache = cacheManager.getCache("products")
cache?.evict(event.productId)
}
}Option B: Redis Pub/Sub for Cross-Service Invalidation
// Publisher — the service that made the change
@Service
class ProductService(private val redisTemplate: RedisTemplate<String, String>) {
fun updateProduct(id: Long, request: UpdateProductRequest): Product {
val updated = repository.save(request.toEntity(id))
// Notify all services
redisTemplate.convertAndSend(
"cache:invalidate:product",
id.toString()
)
return updated
}
}
// Subscriber — any service that caches product data
@Component
class CacheInvalidationSubscriber(
private val cacheManager: CacheManager
) : MessageListener {
override fun onMessage(message: Message, pattern: ByteArray?) {
val productId = String(message.body)
cacheManager.getCache("products")?.evict(productId)
log.info("Invalidated product cache for id=$productId")
}
}Option C: CDC (Change Data Capture) with Debezium
The most robust approach for complex systems. Debezium reads your database's transaction log and publishes every change as an event.
@Component
class ProductChangeConsumer {
@KafkaListener(topics = ["dbserver1.public.products"])
fun handleProductChange(event: ChangeEvent) {
when (event.operation) {
"u" -> { // Update
val id = event.after["id"] as Long
cache.evict("product:$id")
}
"d" -> { // Delete
val id = event.before["id"] as Long
cache.evict("product:$id")
}
}
}
}CDC catches everything — even direct SQL updates, migrations, and batch jobs. It's the most reliable invalidation strategy, but also the most complex to set up.
Strategy 4: Versioned Keys
Instead of invalidating, never overwrite. Write to a new key that includes a version number.
@Service
class ProductService(
private val redisTemplate: RedisTemplate<String, Any>,
private val repository: ProductRepository
) {
fun getProduct(id: Long): Product {
val version = getVersion(id)
return redisTemplate.opsForValue().get("product:$id:v$version")
?: fetchAndCache(id, version)
}
fun updateProduct(id: Long, request: UpdateProductRequest): Product {
val updated = repository.save(request.toEntity(id))
// Increment version — old keys naturally expire via TTL
val newVersion = incrementVersion(id)
redisTemplate.opsForValue().set(
"product:$id:v$newVersion", updated, Duration.ofHours(1)
)
return updated
}
}Pros: No invalidation needed. Old versions naturally expire via TTL. No race conditions. Cons: More keys in Redis. Need a version counter (can use Redis INCR).
Invalidation at Scale
When you have multiple regions or services, invalidation gets harder:
| Scale | Strategy |
|---|---|
| Single service | Explicit invalidation (@CacheEvict) |
| Multiple services | Redis Pub/Sub |
| Multiple teams | CDC with Debezium + Kafka |
| Multiple regions | Versioned keys + regional Redis replication |
The key insight: invalidation complexity scales with the number of independent writers and readers. A monolith can get away with explicit invalidation. A distributed system needs event-driven or CDC-based approaches.
Key Takeaways
- TTL is the simplest — set it and forget it, but data can be stale
- Explicit invalidation works for single-service architectures
- Pub/Sub bridges multiple services — Redis makes this easy
- CDC (Debezium) catches every change, even direct DB updates — the gold standard for complex systems
- Versioned keys avoid invalidation entirely at the cost of more keys
- Pick your strategy based on how many independent systems write and read the same data
Tags
Related Posts

Caching Part 9: Hands-On — Code Labs and Real Implementations
Copy-paste-ready caching implementations — Spring Cache annotations, two-level caching with Caffeine + Redis, and a full Docker Compose setup.

Caching Part 7: In Production — Monitoring, Sizing, and Staying Alive
The operational side of caching — what to monitor, how to size your cache, high availability setup, and surviving failures gracefully.

Caching Part 3: Strategies — Read-Through, Write-Through, and Beyond
The 5 core caching patterns every backend engineer needs — when to use each one, with real trade-off analysis and Java/Kotlin examples.