Caching strategies: cache-aside, invalidation, and stampedes
The dashboard query costs 180 milliseconds, runs on every page load, and returns data that changes maybe once an hour. Caching it is obviously right, and that is exactly how caching gets you: the first decision is easy, and every decision after it is about correctness. What happens when the underlying data changes? What happens when ten thousand requests arrive in the second after the cached copy expires? A caching layer is a correctness problem wearing a performance costume. The strategies below are the industry's accumulated answers, starting with the one you should reach for by default.
Cache-aside: the default for a reason
Nearly every cache you build should start as cache-aside, so it is worth stating the flow precisely. The application owns all the logic; the cache is a passive key-value store that never talks to the database itself.
On a read, the application checks the cache first. A hit returns the cached value directly, and the database never hears about the request. A miss falls through to the database, and the caller that missed writes the result back into the cache with a time-to-live (TTL) before returning it. The next reader hits.
Hit
answered from memory; the database never hears about it
Miss
the app asks the cache, misses, reads the database itself, and stores the value with a TTL on the way back
Stampede
the hot key expired; every caller misses at once and the database gets all of them
In code, the whole strategy is one method:
public async Task<Product?> GetProductAsync(int id)
{
var key = $"product:{id}";
var cached = await _cache.GetAsync<Product>(key);
if (cached is not null)
{
return cached; // hit: memory answers, the database rests
}
var product = await _db.Products.FindAsync(id); // miss: source of truth
if (product is not null)
{
await _cache.SetAsync(key, product, TimeSpan.FromMinutes(5));
}
return product;
}
Cache-aside earns its default status through its failure behavior. If the cache dies, every read degrades into a database read: slower, but correct. The cache holds only data something actually asked for, and the database remains the single source of truth. The costs are that every miss pays two trips (the database read, then the cache write) and that the strategy says nothing yet about staleness. That silence is where the hard problems live, and we will get to them.
Write-through and write-behind
Write-through inverts the relationship: writes go to the cache, and the cache synchronously writes to the database before acknowledging. Reads become simple and the cache is never stale with respect to writes that went through it. The trade-off is just as plain: every write pays for both systems in latency, you spend cache memory on data nobody may ever read, and every writer must cooperate. One process updating the database directly quietly breaks the guarantee for everyone.
Write-behind acknowledges after updating only the cache and flushes to the database later, in batches. It makes write-heavy workloads dramatically faster, and it is dangerous by default: the cache briefly holds the only copy of acknowledged data, so a cache node dying loses committed writes unless the cache itself is replicated and persistent. Use it when losing a small window of writes is acceptable, or when the cache underneath makes durability promises you have actually verified.
For most applications the answer stays boring. Read through cache-aside, write straight to the database, and handle staleness explicitly. Which raises the question of how.
Invalidation: every value needs an owner
A TTL is not a cleanup mechanism. It is a staleness budget: a declaration that this value may be up to five minutes behind reality and the business is fine with that. Phrased that way, the right TTL stops being a technical question. Product descriptions might tolerate an hour. Inventory counts might tolerate thirty seconds. A user's own profile edits tolerate roughly zero, because nothing erodes trust like saving a change and not seeing it.
Every cached value is a claim about how stale the business can tolerate being. If nobody can state that number, the cache is not a strategy. It is a bug with good latency.
When the budget is genuinely zero, TTL alone cannot save you, and you add event-driven invalidation: the code path that changes the data also deletes the affected keys. Delete, not update, in cache-aside, because the next reader will miss and rebuild from the source of truth, which avoids racing a stale write against a fresh one. Event-driven invalidation is precise, and it is also where the famous difficulty lives, since every write path must now know every key its change affects. That mapping is the real cost. It is why the practical rule is organizational as much as technical: a cached value ships with an owner who can answer "how stale is acceptable, and what invalidates this?" If nobody can, the value has no business being cached.
The stampede
Here is the failure that turns a caching win into an outage. One hot key, say the homepage product list, expires. In the next 50 milliseconds, four hundred concurrent requests all miss, and all four hundred obediently execute the miss path against the database at once. The query that ran once per five minutes is now running four hundred times in parallel. The database chokes, the misses get slower, more requests pile in behind them, and a cache that carried 99 percent of your traffic has just redirected all of it at the one component sized for the remaining 1 percent. The literature calls it a stampede or a dogpile. Postmortems call it "the cache made it worse." From the database's seat it is simply demand with no ceiling, the exact thing rate limiting exists to prevent at the front door.
Three fixes compose nicely.
Single-flight locking. Only one caller per key gets to rebuild. The others either wait briefly for the winner's result or are handed the just-expired value in the meantime. In process, a per-key semaphore is enough; across instances, a short distributed lock does the same job. The database sees one rebuild instead of four hundred.
TTL jitter. Keys written at the same moment expire at the same moment, and deploys or cache flushes synchronize whole key families at scale. Adding random noise to every TTL spreads the expirations out so they cannot land in unison:
var baseTtl = TimeSpan.FromMinutes(5);
var jitter = TimeSpan.FromSeconds(Random.Shared.Next(0, 60));
await _cache.SetAsync(key, value, baseTtl + jitter);
Serve stale while refreshing. Keep values past their logical expiry and let one background refresh replace them while readers keep receiving the slightly old copy. This trades a bounded amount of extra staleness for never exposing a miss storm at all. Libraries such as FusionCache and Caffeine ship it as a first-class mode, and it pairs well with the staleness budgets from the previous section: if the owner already accepted five minutes, five minutes and ten seconds rarely changes the conversation.
Hit ratio decides whether the cache earns its keep
A cache adds a second system, a second failure mode, and a class of bugs (staleness) that did not exist before it. The number that justifies all of that is the hit ratio: hits divided by total lookups. A cache serving 95 percent of reads from memory has bought you real headroom. A cache hitting 40 percent is mostly a distraction with a network hop, and its keys or TTLs need rethinking before its capacity does. Measure it per key family rather than globally, because a healthy overall ratio can hide one hot family at 20 percent that still hammers the database. If you cannot see the hit ratio, you do not have a caching strategy. You have hope.
One more honest question belongs before any of this machinery: does the read deserve to be slow? A query that crawls because it scans a table is fixed for good by an index, with no staleness budget to negotiate and no herd to manage. The best cache is often the one you realize you do not need.
Practicing against the herd
The concepts in this article are easy to nod along with and easy to get wrong under pressure, because the failure modes only show up when real concurrency meets real infrastructure. That combination is hard to rehearse on a laptop. Katabench's guided labs walk you through real running systems step by step, and the practice tracks grade every submission on what it actually did, from the queries it issued to the wall-clock time and memory it cost. Build the habit of asking "what does this code really do under load?" and the caching decisions in this article stop being a checklist. They become the obvious next question.