Caching
Keep an expensive answer close, and know when it is stale or unsafe to share
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
Your API runs on four instances behind a load balancer. Which cache gives all four the same cached value?
Real-world analogy
What is it?
Caching means saving the result of expensive work so you can reuse it. In ASP.NET Core you have IMemoryCache for data inside one process and IDistributedCache, usually backed by Redis, for data shared by all instances. The usual pattern is cache-aside: read the cache, and on a miss load from the database and store the result with an expiry. The hard parts are knowing when cached data is stale, and making sure a cached item is safe to share with whoever asks for it.
Real-world relevance
A product page that reads the same price list from the database ten thousand times an hour is a good cache candidate, since the data is shared and changes rarely. Teams get hurt in two ways. A cache with no invalidation shows old prices after an update. A cache key built without the user id shows one customer's cart to another, which becomes a privacy incident rather than a bug. Both are why reviewers look hard at every cache key.
Key points
- A cache keeps answers close — It stores the result of slow work, such as a query, so the next request can reuse it. It trades some freshness for speed and lower load.
- IMemoryCache is per instance — It lives inside one app process and is very fast. Each server instance has its own copy, so instances can disagree.
- Distributed cache is shared — Redis through IDistributedCache is a separate store that all instances use. With several instances, this gives one shared answer, at the cost of a network hop.
- Cache-aside read path — Check the cache. On a miss, read the database, put the result in the cache and return it. The application controls the cache, not the database.
- Absolute vs sliding expiry — Absolute expiry removes the entry after a fixed time. Sliding expiry resets the timer on each read. A popular entry can then stay forever, unless you also set an absolute limit.
- Invalidation is the hard part — When the data changes, the cached copy is wrong. Remove or update the entry on write. A short expiry is a safety net for the times a removal is missed.
- Cache stampede — When a popular entry expires, many requests miss at once and all hit the database. Softening it means random expiry offsets, a lock so one request rebuilds the value, or refreshing before expiry.
- Key by who may see it — Data that differs per user or tenant needs that id in the key, or one user gets another's data. Output caching stores whole HTTP responses, and by default skips authenticated requests for this reason.
Quick check
Answer from memory — scrolling up first makes it stick less.
What is the cache-aside read path?
Why is invalidation called the hard part of caching?
Code example
public record ProductDto(int Id, string Name, decimal Price);
public class ProductCatalog(ShopContext db, IDistributedCache cache)
{
// Product data is the same for every user, so the key needs only the product id
private static string Key(int id) => $"product:{id}";
public async Task<ProductDto?> GetAsync(int id, CancellationToken ct)
{
var json = await cache.GetStringAsync(Key(id), ct);
if (json is not null)
return JsonSerializer.Deserialize<ProductDto>(json); // cache hit
var product = await db.Products.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(ct); // miss: ask the database
if (product is null) return null;
await cache.SetStringAsync(Key(id), JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }, ct);
return product;
}
public async Task UpdatePriceAsync(int id, decimal price, CancellationToken ct)
{
await db.Products.Where(p => p.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, price), ct);
await cache.RemoveAsync(Key(id), ct); // invalidate on write
}
}Line-by-line walkthrough
- 1. ProductDto is the small shape that gets stored in the cache, serialized as JSON.
- 2. Key builds the cache key from the product id. This data is public, so no user id is needed.
- 3. GetAsync first asks the cache. A non-null string is a hit, and the method returns it without touching the database.
- 4. On a miss, a projected query reads only the three needed columns from the database.
- 5. SetStringAsync stores the result for ten minutes, an absolute expiry that acts as a safety net.
- 6. UpdatePriceAsync updates the row, then removes the key so the next read loads the fresh price.
- 7. This is not perfect. A reader that loaded the old row just before the update can write it back after the removal. The ten-minute expiry limits how long that stale value lives.
Spot the bug
public async Task<CartDto> GetCartAsync(int userId, CancellationToken ct)
{
var json = await cache.GetStringAsync("cart", ct);
if (json is not null) return JsonSerializer.Deserialize<CartDto>(json)!;
var cart = await carts.LoadAsync(userId, ct);
await cache.SetStringAsync("cart", JsonSerializer.Serialize(cart),
new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(20) }, ct);
return cart;
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Caching in ASP.NET Core overview (Microsoft Learn)
- Distributed caching in ASP.NET Core (Microsoft Learn)
- Output caching middleware in ASP.NET Core (Microsoft Learn)
Lock it in