Cache Invalidation: TTL, Event-Driven & Tag Strategies
There are only two hard things in Computer Science: cache invalidation and naming things. Here is a practical guide to getting the first one right.
Why Invalidation Matters#
Caching without a clear invalidation strategy leads to stale data, confused users, and debugging nightmares. Every cache entry needs an answer to: when does this stop being correct?
1. TTL-Based Invalidation#
The simplest approach: every cached value expires after a fixed duration.
SET user:42:profile "{...}" EX 300 # expires in 5 minutes
Pros: Simple, predictable, self-healing. Cons: Data can be stale for up to the full TTL window. Too short wastes cache; too long serves outdated data.
When to use: Read-heavy data that tolerates brief staleness (product listings, public profiles).
Adaptive TTL#
Vary TTL based on how frequently data changes:
def adaptive_ttl(last_modified, base_ttl=300):
age = time.time() - last_modified
if age > 86400: # not modified in 24h
return base_ttl * 4
elif age > 3600: # not modified in 1h
return base_ttl * 2
return base_ttl
2. Event-Driven Invalidation#
Invalidate the cache the moment data changes by publishing events.
User updates profile
-> publish "user:42:updated"
-> cache subscriber deletes user:42:profile
Pros: Near-zero staleness. Cons: Requires event infrastructure (Kafka, Redis Pub/Sub, SNS). Adds coupling between write path and cache layer.
When to use: Consistency-critical data (inventory counts, account balances).
3. Write-Through Invalidation#
Every write updates the cache and the database in a single operation.
def update_user(user_id, data):
db.update("users", user_id, data)
cache.set(f"user:{user_id}:profile", serialize(data), ex=3600)
Pros: Cache is always fresh immediately after writes. Cons: Write latency increases. If the cache write fails, you get inconsistency.
Variant — Write-Behind (Write-Back): Write to cache first, asynchronously flush to DB. Faster writes but risk of data loss.
4. Cache-Aside (Lazy Loading) Invalidation#
The application manages the cache explicitly:
def get_user(user_id):
cached = cache.get(f"user:{user_id}")
if cached:
return deserialize(cached)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
cache.set(f"user:{user_id}", serialize(user), ex=600)
return user
def update_user(user_id, data):
db.update("users", user_id, data)
cache.delete(f"user:{user_id}") # invalidate, don't update
Key insight: On write, delete the cache entry rather than updating it. The next read will repopulate from the source of truth.
5. Tag-Based Invalidation (Surrogate-Key)#
Assign tags to cached responses so you can invalidate groups at once.
Cache-Tag: product, product:42, category:electronics
When a product updates, purge everything tagged product:42. When a category changes, purge category:electronics.
CDN support: Fastly (Surrogate-Key), Cloudflare (Cache-Tag), Varnish (xkey).
# Fastly purge by surrogate key
curl -X POST "https://api.fastly.com/service/{id}/purge/product:42" \
-H "Fastly-Key: $TOKEN"
When to use: Pages or responses that aggregate multiple entities.
6. Versioned Keys#
Embed a version number in the cache key itself:
user:42:profile:v17
When data changes, increment the version. Old keys expire naturally via TTL. No explicit deletion required.
def get_user(user_id):
version = db.query("SELECT cache_version FROM users WHERE id = %s", user_id)
key = f"user:{user_id}:profile:v{version}"
cached = cache.get(key)
if cached:
return deserialize(cached)
# ...fetch and cache with this key
Pros: No race conditions, no explicit invalidation. Cons: Stale versions linger until TTL expires (memory overhead).
7. Purge APIs#
Expose an explicit endpoint to invalidate specific cache entries:
POST /api/admin/cache/purge
{ "patterns": ["user:42:*", "feed:home"] }
Useful for manual intervention and CI/CD pipelines (deploy triggers purge).
Guard it: Purge endpoints must be authenticated and rate-limited.
8. Stale-While-Revalidate#
Serve the stale cached value immediately while fetching a fresh copy in the background.
Cache-Control: max-age=60, stale-while-revalidate=300
This means: serve from cache for 60 seconds. After that, serve stale for up to 300 more seconds while revalidating asynchronously.
def get_with_swr(key, fetch_fn, ttl=60, swr_window=300):
entry = cache.get_with_metadata(key)
if entry and not entry.expired:
return entry.value
if entry and entry.age < ttl + swr_window:
# Serve stale, refresh in background
background_refresh(key, fetch_fn, ttl)
return entry.value
# Cache miss — blocking fetch
value = fetch_fn()
cache.set(key, value, ex=ttl)
return value
Pros: Users never wait for cache misses. Cons: Briefly serves stale data during revalidation.
9. The Dogpile Effect (Cache Stampede)#
When a popular cache key expires, hundreds of requests simultaneously hit the database.
Solutions#
Locking: Only one request recomputes; others wait or get stale data.
def get_with_lock(key, fetch_fn, ttl=300):
value = cache.get(key)
if value:
return value
lock_key = f"lock:{key}"
if cache.set(lock_key, "1", nx=True, ex=10): # acquire lock
value = fetch_fn()
cache.set(key, value, ex=ttl)
cache.delete(lock_key)
return value
else:
time.sleep(0.05)
return cache.get(key) # retry after brief wait
Probabilistic early expiration: Randomly refresh before TTL expires.
import random, math
def should_recompute(entry, beta=1.0):
remaining = entry.expiry - time.time()
jitter = beta * math.log(random.random()) * -1
return remaining < jitter
Pre-warming: Refresh popular keys on a schedule before they expire.
Choosing the Right Strategy#
| Strategy | Consistency | Complexity | Best For |
|---|---|---|---|
| TTL | Eventual | Low | General caching |
| Event-driven | Strong | High | Critical data |
| Write-through | Strong | Medium | Write-heavy |
| Cache-aside + delete | Eventual | Low | Read-heavy |
| Tag-based | On-demand | Medium | CDN / aggregated pages |
| Versioned keys | Strong | Low | Immutable-style data |
| Stale-while-revalidate | Eventual | Medium | User-facing latency |
Combining Strategies#
Production systems rarely use just one approach:
CDN layer -> Tag-based + stale-while-revalidate
App cache -> Cache-aside + event-driven invalidation
Session cache -> TTL (short, 15 min)
Config cache -> Write-through + TTL (long, 1h)
Key Takeaways#
- Delete on write is safer than update on write for cache-aside.
- TTL is your safety net even when using event-driven invalidation.
- Dogpile protection is essential for high-traffic keys.
- Tag-based invalidation simplifies CDN cache management dramatically.
- Stale-while-revalidate gives users instant responses without sacrificing freshness.
This is article #266 in the Codelit engineering series. Explore more backend architecture, system design, and performance guides at codelit.io.
Try these templates
Small SaaS Launch Architecture
A single application with private file storage, tenant-scoped records, and idempotent billing events. Start small; scale from measured demand.
5 componentsFeedback to Roadmap Architecture
Preserve original evidence separately from model suggestions and require review before publishing roadmap changes.
5 componentsWebsite Monitoring Architecture
Isolate browsing, store dated evidence, and separate scheduled checks from approved report delivery.
5 componentsContinue learning
Go deeper on data and storage
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Fundamentals of Data Engineering
Joe Reis, Matt Housley · 2022
Covers the pipeline lifecycle from ingestion to serving, with trade-off tables for each stage.
4.7 (897)#3 in Data MiningRedis in Action
Josiah L. Carlson · 2013
Cache, lock, queue and rate-limit patterns in Redis written out as complete code, not snippets.
3.8 (27)A Philosophy of Software Design
John Ousterhout · 2021
Short book on module depth and complexity, with before/after code showing why an interface is too wide.
Fundamentals of Software Architecture
Mark Richards, Neal Ford · 2025
Scores eight architecture styles against the same characteristics, so trade-offs are side by side.