Caching Strategies
By the end of this lesson
Cache effectively and handle invalidation deliberately.
A cache is a copy of an answer, kept somewhere cheaper to reach than the place that produced it. The outstanding-balance figure took 180 milliseconds to calculate across four tables; held in memory it comes back in one. Nothing about the calculation improved. You are reusing the result.
Two questions decide whether a cache helps or hurts, and only the first one gets attention. What is worth copying, and how do you know when the copy has stopped being true? The second question is the whole difficulty. A cache is a second source of truth, and two sources of truth can disagree.
The strategies, and what each one costs:
- Cache-aside
- The caller looks in the cache, and on a miss fetches the data and stores it. Simple, explicit, and the default choice. Its weakness is that every caller has to remember, so one code path that reads around the cache makes your reads inconsistent.
- Read-through
- The same behaviour moved behind one component, so callers see a query and do not know a cache exists. Fewer places to get wrong, at the price of a layer where a stale read is less obvious to whoever is debugging it.
- Write-through
- Writes update the store and the cache together, so the cache is never behind. It costs latency on every write and it only works while every write goes through that path. The overnight job that writes directly makes the cache wrong without anybody noticing.
- Write-behind
- Writes go to the cache and are flushed to the store later. Fast, and it accepts data loss if the cache dies before the flush. Reasonable for click counts; not for invoices.
- Absolute expiry
- The entry dies at a fixed age. Predictable, and it gives you a stated maximum staleness you can tell the business about.
- Sliding expiry
- The clock resets on each read. Useful for session-shaped data, and dangerous for anything that must eventually refresh: a key read every few seconds never expires, so it can be stale for days.
public sealed class CachedInvoiceSummaries(
IDistributedCache cache,
IInvoiceSummaryQuery inner,
ITenantContext tenant) : IInvoiceSummaryQuery
{
private static readonly DistributedCacheEntryOptions Expiry = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2),
};
public async Task<InvoiceSummary> GetAsync(int customerId, CancellationToken token)
{
// Every input that changes the answer appears in the key, including
// the tenant. "v2" is bumped when InvoiceSummary changes shape.
string key = "invoice-summary:v2:t" + tenant.TenantId + ":c" + customerId;
byte[]? cached = await cache.GetAsync(key, token);
if (cached is not null)
return JsonSerializer.Deserialize<InvoiceSummary>(cached)!;
InvoiceSummary fresh = await inner.GetAsync(customerId, token);
await cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(fresh), Expiry, token);
return fresh;
}
}- The class implements the same interface it wraps, so callers are unchanged and the cache can be removed by altering one registration. That also means no caller can accidentally read around it.
- The tenant identifier is in the key because the answer depends on it. Any input that changes the answer belongs there: tenant, user, role, currency, locale, permission scope. Leave one out and the cache serves one caller's answer to another.
- The version segment is an escape hatch. Add a field to InvoiceSummary and bump it to v3, and existing entries are ignored rather than deserialised into the wrong shape after a deployment.
- Two minutes is a decision, not a default. It says: the business accepts that this figure can be up to two minutes old. Someone should agree with that sentence before it ships.
- This code has a gap worth spotting now. Under load, two hundred concurrent requests for the same missing key all miss, and all two hundred run the underlying query. The cache was meant to protect the database and at that moment it does the opposite.
- The serialisation is not free either. Caching an object that takes three milliseconds to serialise in order to avoid a two-millisecond query makes things worse, which is why hit rate and timing are worth measuring rather than assuming.
There are two ways to stop serving something that is no longer true. Let it expire, or remove it when the underlying data changes. They fail differently, so most systems need both.
Expiry is self-healing and needs no cooperation from anyone. It also guarantees a window in which you knowingly serve the wrong answer. Explicit removal is exact and fragile: every path that changes the data has to remember, including the overnight job, the admin screen, the import and the support script somebody runs by hand. The path that forgets is always the one you did not know about.
A third option avoids removal entirely: put a version in the key and change the version. Old entries are never read again and age out on their own. That works well when you cannot enumerate keys, and it costs memory you are paying for until the entries expire.
Whichever you choose, be honest with yourself about what you have built. There are now two places that answer the same question, and they can disagree. When a report shows one total and the detail screen shows another, this is usually the reason, and the investigation is slow because both numbers are internally consistent.
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5), // shared copy
LocalCacheExpiration = TimeSpan.FromSeconds(20), // in-process copy
};
});
public sealed class InvoiceSummaries(
HybridCache cache,
IInvoiceSummaryQuery inner,
ITenantContext tenant)
{
public ValueTask<InvoiceSummary> GetAsync(int customerId, CancellationToken token) =>
cache.GetOrCreateAsync(
key: "invoice-summary:v2:t" + tenant.TenantId + ":c" + customerId,
state: (inner, customerId),
factory: static async (s, ct) => await s.inner.GetAsync(s.customerId, ct),
tags: ["customer:" + customerId],
cancellationToken: token);
// Called by the write side, after the transaction has committed.
public ValueTask InvalidateAsync(int customerId, CancellationToken token) =>
cache.RemoveByTagAsync("customer:" + customerId, token);
}- GetOrCreateAsync runs the factory for one caller per key and makes the rest wait for that result. The two hundred concurrent misses from the previous example become one query, without a lock you had to write.
- Two layers, two lifetimes. The in-process copy removes a network hop for twenty seconds; the shared copy lasts five minutes. The consequence is worth stating: for up to twenty seconds, different instances can return different answers, so the maximum staleness a user can see is the longer of the two windows.
- Tags let the write side invalidate everything about one customer without knowing which keys exist. Keeping a list of key patterns in the write code is the alternative, and it drifts out of date within a release or two.
- Invalidate after the commit, never before. Remove the entry first and a concurrent read can repopulate it from the uncommitted state, leaving the cache wrong until it expires — a defect that appears under load and cannot be reproduced by hand.
- The static factory with a state tuple avoids capturing a closure on every call. A small thing, and free once you know the shape.
- What this does not do is make the cache safe to depend on. If the shared cache is unreachable, the call should behave like a miss and serve a slower correct answer. An application that fails when its cache fails has swapped one dependency for two.
The failure modes that arrive with scale, and what answers each:
- Stampede. A popular entry expires and every concurrent request misses at once. Let one caller populate while the others wait, and add a small random offset to expiry times so entries created together do not all die together.
- Cold start. A deployment empties every instance's in-process cache simultaneously, so the database takes the full load at the moment you are least able to investigate. Stagger the rollout, or warm the few hottest keys on startup.
- The cache becoming a hard dependency. Treat an unreachable cache as a miss and keep serving. Otherwise a cache outage is an application outage, which is the opposite of the resilience you were buying.
- Unbounded keys. Caching per unique query string fills the cache with entries read once and evicts the handful that mattered. Cache the few hot answers deliberately.
- Expensive misses that are not cached. If "no outstanding invoices" takes as long to compute as a real answer, cache the absence too, briefly, or every empty result runs the full query.
- Mutable objects handed out from an in-process cache. IMemoryCache returns the same instance to every caller, so one caller changing a property changes it for everybody. Cache immutable types, or a copy.
- No measurement. A cache with a four percent hit rate is pure cost: extra latency, extra failure modes, extra staleness. Record hit rate, miss latency and entry count before defending it.
Summary
- A cache reuses an answer; it does not make the work that produced it faster
- Everything the answer depends on belongs in the key, or one caller gets another's data
- Invalidate after the commit, and keep a short expiry as the backstop for paths you missed
- Expect stampedes and cold starts, and treat an unreachable cache as a miss
- Cache what is displayed; read fresh what a decision depends on
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Audit every key you have
List every cache key an application you know produces. For each one, write down what the answer depends on — user, tenant, role, currency, date — and check that each of those appears in the key.
Then check the expiry, and whether anything explicitly removes the entry when the data changes.
Show solution
The usual finding is at least one key missing an axis. Tenant is the most commonly forgotten, then role, because in development everybody tests as an administrator in a single tenant and the defect cannot appear.
The second finding is entries with no invalidation at all, where a TTL is doing all the work. That is not automatically wrong — a short TTL is a legitimate strategy — but it should be a decision with a number attached, not an omission.
The third is the reverse: explicit removal on the main write path and nothing on the import job or the admin screen. Add the short TTL as a backstop for the paths you have not found yet.
Do the audit as a test where you can. A test that asserts a key contains the tenant identifier looks trivial and prevents the one failure on this page that reaches a customer's data.
Think about it
Cache it, and for how long?
Decide for each: the product catalogue shown on every page; a customer's outstanding balance; the current VAT rate; the signed-in user's permissions; the result of a credit check used to approve payment terms.
Give a duration and say who has to agree with it.
Show solution
Product catalogue: cache it, minutes to hours. Read constantly, changed by a small number of people, and a few minutes of staleness on a description is nobody's problem. Invalidate on publish so an urgent correction does not wait.
Outstanding balance: cache it briefly, tens of seconds, keyed by tenant and customer, and only for display. Do not cache it for a decision — see the credit check below. Finance has to agree to the number on the screen being a moment behind.
VAT rate: cache it for a long time, and version the key. It changes on a legal schedule with notice, so staleness is nearly free. The trap is the change date: an entry with a sliding expiry can keep the old rate past midnight on the day it changes, so use absolute expiry and invalidate on publish.
Permissions: cacheable for a short, deliberate window, and this is a security decision rather than a performance one. The question is how long a revoked permission may keep working. Ten seconds is usually acceptable; ten minutes usually is not, and whoever owns access control has to say which.
Credit check for approving payment terms: do not cache for the decision. Read it fresh, record the answer against the order, and cache only the recorded answer for display. The general rule falls out of this list: cache what is displayed, read fresh what is decided upon.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.