Caching strategies#
Making the database do less work by not asking it the same question twice.
The main patterns#
Pattern | How it works | Trade off |
|---|---|---|
Cache aside | App checks cache first, on a miss reads the database and fills the cache | Simple, but the first request after any miss is always slow |
Write through | Every write goes to the cache and the database together | Cache stays fresh, but every write is now two operations |
Write behind | Writes go to the cache immediately, database gets updated asynchronously | Fast writes, but a crash before the async write can lose data |
Cache aside is the default most teams reach for first, it's the easiest to reason about and to remove later if it's not helping.
Cache aside in practice#
def get_user(id):
cached = redis.get(f"user:{id}")
if cached:
return cached
user = db.query("SELECT * FROM users WHERE id = ?", id)
redis.set(f"user:{id}", user, expire=300)
return user
The expire=300 matters as much as the caching itself. Without it, a cache never forgets, and you'll be debugging "why is this data six months stale" instead of a slow query.
Cache invalidation#
The famously hard part. Two honest approaches:
Time based (TTL) - just let entries expire after a fixed window. Simple, and wrong for at most that window of time.
Event based - explicitly delete or update the cache entry whenever the underlying data changes. Correct, but easy to miss a code path that writes to the database without touching the cache.
Note
Most production systems use both. TTL as a safety net in case an invalidation gets missed, event based invalidation for anything that needs to feel instant.
What not to cache#
A few things that usually don't belong in a cacheAnything where a stale read causes real harm, account balances, inventory counts right before checkoutData tha…
Anything where a stale read causes real harm, account balances, inventory counts right before checkout
Data that changes on almost every read anyway, caching buys you nothing if the cache would miss most of the time
Anything already fast. Caching a query that takes 2ms adds complexity for a saving nobody will notice
Caching is a trade of correctness for speed. Reach for it once you know exactly which queries are slow and how stale a response can safely be, not as a first move.
Read through caching#
Similar to cache aside, but the cache itself, not the app, is responsible for fetching from the database on a miss. The app only ever talks to the cache. Less code in the app, but means the cache layer needs to know how to query the database, which not every caching tool supports out of the box.
Cache stampede#
A popular cache entry expires, and a burst of simultaneous requests all miss at once and all hit the database at the same time trying to refill it, sometimes bringing it down.
A common fixHave only the first request that misses actually query the database, while the rest wait briefly for that result instead of also querying.
Have only the first request that misses actually query the database, while the rest wait briefly for that result instead of also querying. Often called request coalescing or a "single flight" pattern.
Local cache vs distributed cache#
Type | Where it lives | Trade off |
|---|---|---|
Local | In each app server's own memory | Fastest possible reads, but every server has its own copy, so invalidation has to reach all of them |
Distributed | A separate service like Redis, shared by all app servers | One copy to invalidate, but every read is a network call |
Many production systems use both, a small local cache for extremely hot data, backed by a distributed cache for everything else.
Cache warming#
Starting with an empty cache means the first wave of requests after a deploy or restart all miss at once. Warming pre-loads known hot data before traffic arrives, avoiding that cold start slowdown.
Eviction policies#
When a cache fills up, something has to be removed to make room.
Policy | Removes | Good for |
|---|---|---|
LRU | Least recently used | General purpose, the sensible default |
LFU | Least frequently used | Data with a stable set of hot items over time |
TTL only | Whatever's oldest by time, regardless of use | Data with a natural, known freshness window |
Caching at multiple layers#
A CDN can cache full responses at the edge, the app can cache query results, and the database itself caches data in memory. Each layer removes work from everything behind it. The earliest layer that can safely serve a request is almost always the cheapest place to serve it from.
Caching computed and aggregated data#
Some of the best caching targets aren't raw rows, they're the result of an expensive aggregation, like a dashboard total or a leaderboard, that's genuinely slow to compute and doesn't need to be perfectly real time.
Designing cache keys#
user:1234:profile
user:1234:orders:page:2
product:5678:price:region:usA clear, consistent key pattern makes it possible to invalidate related groups of keys together, and avoids accidental collisions between unrelated data that happen to hash similarly.
Measuring whether the cache is actually helping#
Track hit rate, the percentage of reads served from cache instead of the database. A cache with a low hit rate is adding complexity and a network hop for very little benefit, and is worth reconsidering, not just tuning.
Caching and consistency trade offs#
Every cache is a bet that slightly stale data is an acceptable price for speed. That bet is fine for a product listing, and a bad idea for a bank balance. Decide this deliberately per piece of data, not as a blanket rule across the whole app.