What are the main cache invalidation strategies (TTL, explicit invalidation, event-based) and why is cache invalidation famously hard?
Phil Karlton's line about "only two hard things in computer science" is a cliché because every invalidation strategy trades staleness for complexity somewhere.
LEARN BY DOINGPut your intuition to work3 steps
What are the main cache invalidation strategies (TTL, explicit invalidation, event-based) and why is cache invalidation famously hard?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why "just clear the cache" is never the whole answer
Populating a cache is the easy 20% of caching. The hard 80% is deciding when a cached value is no longer true — which is why the line "there are only two hard things in computer science: cache invalidation and naming things" gets repeated in nearly every backend interview. It's a joke with a real mechanism behind it: correctness now depends on a fact (has this changed?) that lives outside the cache entirely, in whatever system of record produced the value.
There are three strategies teams reach for, usually in combination rather than alone.
#1. TTL (time-to-live) expiration
The simplest strategy: every cached entry carries an expiration time, after which it's treated as gone whether or not the underlying data changed.
SET product:1001 '{"name":"Widget","price":19.99}' EX 60
or in application code:
redis.setex("product:1001", 60, json.dumps(product))
TTL is attractive because it's self-healing — you never have to prove you found every key that could be affected by a write, because everything expires eventually regardless. The cost is a guaranteed staleness window: for up to 60 seconds here, a client can read a price that's already wrong. Pick the TTL too short and you lose most of the caching benefit (near-constant misses); pick it too long and a price change takes minutes to reach customers. TTLs also create a thundering herd: if you cache ten thousand product rows with the same 60-second TTL set at deploy time, they all expire together, and ten thousand simultaneous cache misses hit the database at once. Jittering the TTL (60 + random(0, 10) seconds) is the standard fix.
#2. Explicit invalidation
Instead of waiting for expiry, the write path actively deletes or updates the specific cache key(s) affected by a change.
def update_price(product_id, new_price):db.execute("UPDATE products SET price = %s WHERE id = %s", new_price, product_id)redis.delete(f"product:{product_id}")
This minimizes the staleness window to roughly the time between the write and the next read. The catch is that it requires knowing, at write time, every cache key that could be derived from the changed row. A single product's cache key is easy. A "top 10 best-selling products this week" cache key, or a search-results page cache that embeds this product, is not obviously invalidated by a price change on product 1001 — there's no single key to delete, because the cached value is an aggregate over data you didn't track a dependency on. Teams solve this with tag-based invalidation (associate cached aggregates with the ids that fed them, and invalidate all aggregates tagged with an id whenever that id changes) or just accept a short TTL on aggregates as a backstop.
#3. Event-based invalidation
At larger scale, the service that owns the data doesn't know — and shouldn't need to know — which of a dozen other services cached a derived copy of it. Instead, it publishes a domain event whenever something changes, and every interested service subscribes and invalidates its own cache.
This decouples the writer from every downstream consumer's cache — the product service never needs to learn that a recommendation service exists. The trade-off is that invalidation now happens on a delay determined by the event bus (typically milliseconds, but not zero), and consumers must handle events arriving out of order or more than once — an idempotent invalidation handler (invalidate is idempotent by nature: deleting an already-deleted key is a no-op) makes duplicates harmless, but out-of-order delivery (an old "price changed to $20" event arriving after a newer "price changed to $18" event) can leave a cache pointing at a value older than what a naive handler thinks is current unless events carry a version or timestamp the handler checks before applying.
#The race condition nobody's diagram shows
Even a textbook-correct explicit-invalidation setup has a classic bug hiding in it:
Thread A (reader, cache miss) Thread B (writer)----------------------------- -------------------1. GET product:1001 -> miss2. UPDATE price = 25 in DB3. DEL product:10014. SELECT price FROM products (A already read the OLD price-> returns 20 (A's read started before B's write, from abefore B committed) snapshot/isolation levelthat predates it)5. SET product:1001 = 20 (TTL 60s)
Thread A repopulates the cache with the stale value after thread B already invalidated it — because A's database read raced with B's write. Now the cache confidently serves 20 for up to another 60 seconds even though the correct price is 25, and nothing about the code looks wrong in isolation.
#Choosing a strategy
| Strategy | Staleness window | Complexity | Good fit |
|---|---|---|---|
| TTL only | Up to the full TTL | Lowest | Data that's fine to be slightly stale (product catalogs, non-critical config) |
| Explicit invalidation | Milliseconds, if done right | Medium — must track every derived key | Single-entity caches (user profile, product record) |
| Event-based | Delay of the event bus | Highest — needs idempotency, ordering care | Multi-service architectures, derived/aggregate caches |
🧠 Check your understanding
0/4 · 0/4 answered1. A cache-aside read races with a concurrent write and repopulates the cache with a stale value right after the writer's explicit DEL. What's the standard backstop against this specific failure?
2. Why is a 'top 10 best sellers this week' cache harder to invalidate than a single product's cache entry?
3. What problem does jittering a TTL (e.g., 60 + random(0,10) seconds) solve?
4. In event-based invalidation across services, why must handlers be idempotent?