Caching Is Easy. Cache Invalidation Is Not.
Everyone knows the Phil Karlton quote. It's repeated so often because it's true — not because caching itself is complex, but because the interaction between cached data and source data is where every subtle bug hides.
Let's go beyond the basics. You already know what a cache is. Here we'll dig into the strategies that determine when data enters the cache, when it leaves, and what happens when the truth changes while a stale copy sits in memory.
Cache-Aside (Lazy Loading)
Cache-aside is what most developers implement first, and it's the right default for most read-heavy workloads.
The pattern: application checks the cache. On a miss, it reads from the database, stores the result in cache, and returns it. On subsequent requests, the cache serves the data directly.
On writes, you have two options: invalidate (delete the cache entry) or update (write the new value to cache). I'd almost always recommend invalidation over update. Here's why.
With cache update, you've got a race condition. Two concurrent writes can arrive in different orders at the database and cache, leaving the cache with a stale value that won't be corrected until the TTL expires or another write happens. With invalidation, a stale read can happen only in the brief window between the database write and the cache delete — and the next read repopulates the cache with fresh data.
# Cache-aside with invalidation
class UserService:
def get_user(self, user_id):
cache_key = f"user:{user_id}"
cached = self.redis.get(cache_key)
if cached:
return json.loads(cached)
user = self.db.query_user(user_id)
if user:
self.redis.setex(cache_key, 600, json.dumps(user))
return user
def update_user(self, user_id, data):
self.db.update_user(user_id, data)
self.redis.delete(f"user:{user_id}") # Invalidate, don't update
The Thundering Herd Problem
When a popular cache entry expires, dozens or hundreds of concurrent requests hit the database simultaneously to repopulate it. This can overload your database and cascade into broader failures.
Solutions:
Cache stampede lock: The first request to encounter a miss acquires a short-lived lock. Other requests wait (or return stale data) while the first request refreshes the cache.
Stale-while-revalidate: Serve the expired (stale) data while a background thread refreshes the cache. The user gets a fast response with slightly outdated data; the cache gets updated without a thundering herd. This is the approach Cloudflare uses for their CDN caching.
Randomized TTLs: Instead of all entries for a category expiring at exactly 600 seconds, randomize between 540-660 seconds. This spreads cache misses over time instead of concentrating them at a single moment.
Write-Through
With write-through, every write goes to both the cache and the database as a single operation. The application always writes to the cache, and the cache layer synchronously writes to the database.
The benefit is consistency — the cache is always up to date. The cost is write latency. Every write operation takes the time of a cache write plus a database write. And you're caching data that might never be read — if a user updates their profile and nobody views it for hours, you've burned write bandwidth on a cache entry that expires unused.
Write-through makes sense when reads immediately follow writes (your own profile page after saving) and you need the cache to always reflect the latest state.
Write-Behind (Write-Back)
Write-behind is the risky cousin of write-through. The application writes to the cache, which acknowledges immediately. The cache asynchronously flushes to the database later — typically in batches.
This dramatically reduces write latency and database load. Instead of 100 individual database writes per second, you might batch them into 10 writes per second. Database I/O drops, application response times improve.
The catch is obvious: if the cache node crashes before flushing, you lose data. Any data written to cache but not yet persisted to the database is gone. For this reason, write-behind is appropriate only for data you can tolerate losing: analytics events, view counters, log entries. Never use it for financial transactions or user data.
If you do use write-behind, ensure your cache has replication (Redis Sentinel or Redis Cluster) so a single node failure doesn't lose the write buffer.
Read-Through
Read-through is cache-aside with the data loading logic moved into the cache layer itself. Instead of the application handling cache misses, the cache transparently fetches from the database on a miss.
From the application's perspective, it just reads from the cache. The cache handles population internally. This simplifies application code and centralizes cache-loading logic.
Most cache libraries support this. Caffeine (Java) and lru_cache (Python) are examples of read-through caches, though at the local (in-process) level rather than distributed.
Multi-Layer Caching
In production systems, you'll often have multiple cache layers:
- L1: In-process cache — (e.g., Caffeine, Guava) — nanosecond access, limited by heap size, per-instance (not shared)
- L2: Distributed cache — (e.g., Redis, Memcached) — sub-millisecond access, shared across instances, can hold much more data
- L3: CDN cache — (e.g., Cloudflare, CloudFront) — cached at edge locations worldwide, millisecond access from the user's perspective
- L4: Database query cache — some databases cache query results internally
Read flow: check L1 → L2 → L3 → database. Each layer closer to the user is faster but smaller and more expensive to keep consistent.
The challenge is invalidation across layers. When data changes, you need to invalidate L1 (every app server's local cache), L2 (Redis), and L3 (CDN). Missing any layer means stale data.
For L1 invalidation, publish cache invalidation events via Redis Pub/Sub or a message queue. Each app server subscribes and evicts local entries when notified. It's eventually consistent — there's a brief window where some servers serve stale data — but for most applications that's acceptable.
Cache Warming
After a deployment or a cache flush, your cache is cold. Every request is a miss, and your database suddenly handles the full read load. This can cause a performance cliff right after deploys.
Cache warming preloads frequently accessed data into the cache before traffic hits. You can warm from:
- A list of known hot keys (your top 1000 products, homepage data)
- Access logs from the previous period (replay the most frequent cache keys)
- A shadow traffic system that replays production read traffic against the new cache
For Redis specifically, you can use RDB snapshots. Take a snapshot of the hot cache before deploy, restore it after. The data might be slightly stale, but it's better than a cold start.
Choosing TTLs
There's no universal "right" TTL. It depends on how stale your data can be and how expensive cache misses are.
Some guidelines from production experience: user sessions — 30 minutes to 24 hours. API responses for data that changes rarely (product catalog) — 5 to 60 minutes. Rapidly changing data (stock prices, live scores) — 1 to 10 seconds or no cache at all. Computed aggregations (daily stats, leaderboards) — cache until the next computation cycle.
When in doubt, start with shorter TTLs and increase them based on your miss rate and database load. A 60-second TTL with a 95% hit rate is usually better than a 3600-second TTL with stale data complaints.