Cache Invalidation Strategy
Caching trades freshness for speed. The strategy makes that trade explicit per data class instead of leaving it to defaults. Each class gets a staleness budget, an invalidation mechanism, and a defence against races and stampedes.
How it works
In cache-aside, the application reads the cache, falls back to the source on a miss, and stores the result with a time-to-live. On writes, the application updates the source and then deletes the cache entry, so the next read reloads fresh data. Event-driven invalidation publishes change events that every cache holder consumes. Versioned keys embed a version or content hash, so new data lands under a new key and old entries simply age out.
Implementation steps
- Classify data: static, slowly changing, user-specific, and near-real-time. Set a maximum acceptable staleness for each.
- Choose the mechanism: time-to-live alone for tolerant data, delete-on-write for single-service ownership, event-driven for shared data, versioned keys for immutable content.
- Always set a time-to-live, even with event invalidation, as a safety net.
- Add jitter to expirations so many keys do not expire together.
- Coalesce concurrent misses for one key so only one request hits the source.
- Store a version with each value and refuse to overwrite a newer entry with an older one.
- Cache negative results briefly to protect the source from repeated misses.
Common pitfalls
- Updating the cache and the database in the wrong order, leaving a window for stale repopulation.
- Invalidating on write but not on bulk imports or administrative changes.
- A thundering herd after a cache restart or mass expiry.
- Caching authorised responses under keys that omit the user or tenant.
- Treating cache failure as fatal instead of degrading to the source.
Metrics to watch
- Hit ratio per data class.
- Stale read rate where measurable through sampling.
- Origin request rate on misses and during stampedes.
- Invalidation event lag.
- Cache latency percentiles and eviction rate.
Example configuration
cache:
product-detail:
pattern: cache-aside
ttlSeconds: 300
jitterPercent: 20
invalidateOn: product.updated
coalesceMisses: true
price-list:
pattern: versioned-key
key: "prices:v{version}"
ttlSeconds: 3600
session-profile:
pattern: write-through
ttlSeconds: 60
keyIncludes: [tenantId, userId]
Practical rollout
Before adding a cache, try to measure the staleness that users actually notice, because it is often larger than engineers assume. Start with the simplest mechanism, a time-to-live, and add event-driven invalidation only for classes where staleness causes real harm. Keep cache keys and policies in one reviewed configuration file, so changes are visible, and load test the cold-start path to confirm the origin survives an empty cache.
Key takeaways
- Define a staleness budget for every cached data class.
- Delete on change, and always keep a time-to-live as a backstop.
- Prevent stampedes with coalescing and jitter.
- Use versions to defeat stale-overwrite races.