Caching in ASP.NET Core
In-memory versus distributed caches, absolute versus sliding expiration, cache stampedes, and the cache key that leaks data.
After this lesson you can
- Choose between IMemoryCache, IDistributedCache, HybridCache and output caching
- Predict when an entry with absolute and sliding expiration expires
- Explain a cache stampede and how to prevent one
ASP.NET Core offers several caches, at different layers:
IMemoryCachestores objects in the process. Fast and simple, but each instance has its own copy and a restart empties it.IDistributedCachestores bytes in a shared store such as Redis or SQL Server. Every instance sees the same entries, at the cost of a network hop and serialisation.HybridCacheputs an in-memory layer in front of an optional distributed one and lets concurrent callers for one key in the same process share a single factory call. It ships as a separate NuGet package,Microsoft.Extensions.Caching.Hybrid, not in the shared framework; it was still a preview when .NET 9 shipped and became stable later, so check which version a project references.- Output caching (
AddOutputCache,UseOutputCache) stores whole responses on the server, with policies and tag-based eviction, and the server's policy alone decides what is cached. Response caching follows HTTP caching rules instead, so it is driven byCache-Controlheaders and a client's request headers can bypass it.
Cache-aside and expiration
var product = await cache.GetOrCreateAsync($"product:{id}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.SlidingExpiration = TimeSpan.FromMinutes(2);
return db.Products.FindAsync(id).AsTask();
});
Absolute expiration removes the entry a fixed time after it was created. Sliding expiration removes it after a period with no access, and every read pushes that deadline back. Combined, the entry goes at whichever comes first, so sliding never keeps an entry alive past the absolute limit. Sliding alone can keep a popular entry, and its stale data, forever. The model below applies the two rules to one entry read every 4 time units.
public class Entry{ public int Created, LastAccess; public int? Absolute, Sliding; public bool Alive(int now) => !(Absolute.HasValue && now >= Created + Absolute.Value) && !(Sliding.HasValue && now >= LastAccess + Sliding.Value);} public static class Solution{ static string Trace(string label, Entry e, int[] reads) { var seen = new List<string>(); foreach (var t in reads) { var alive = e.Alive(t); seen.Add($"t={t}:{(alive ? "hit" : "miss")}"); if (!alive) break; e.LastAccess = t; // a hit slides the deadline } return label + " " + string.Join(" ", seen); } public static List<string> Run() { var reads = new[] { 4, 8, 12, 16, 20, 24 }; // created at t=0 return new List<string> { Trace("absolute 20:", new Entry { Absolute = 20 }, reads), Trace("sliding 5:", new Entry { Sliding = 5 }, reads), Trace("both:", new Entry { Absolute = 20, Sliding = 5 }, reads), }; }}Stampedes
GetOrCreate on IMemoryCache does not lock. When a hot entry expires,
every request that arrives before the first one finishes sees a miss and
runs the factory, so the database receives a burst of identical queries
at the worst moment. The model below uses a ConcurrentDictionary as
the cache and forces four threads to miss together.
using System.Collections.Concurrent; public static class Solution{ static void RunFourCallers(Func<string> getProduct) { var threads = Enumerable.Range(0, 4).Select(_ => new Thread(() => getProduct())).ToList(); threads.ForEach(t => t.Start()); threads.ForEach(t => t.Join()); } public static List<string> Run() { // Naive cache-aside: check, miss, then load. The barrier makes all four miss together. var naiveCache = new ConcurrentDictionary<string, string>(); var naiveLoads = 0; var barrier = new Barrier(4); RunFourCallers(() => { if (naiveCache.TryGetValue("product:1", out var hit)) return hit; barrier.SignalAndWait(); Interlocked.Increment(ref naiveLoads); return naiveCache["product:1"] = "Widget"; }); // One Lazy per key: GetOrAdd returns the same stored Lazy to every caller. var lazyCache = new ConcurrentDictionary<string, Lazy<string>>(); var lazyLoads = 0; var barrier2 = new Barrier(4); RunFourCallers(() => { barrier2.SignalAndWait(); return lazyCache.GetOrAdd("product:1", _ => new Lazy<string>(() => { Interlocked.Increment(ref lazyLoads); return "Widget"; })).Value; }); return new List<string> { $"naive: {naiveLoads} loads", $"lazy: {lazyLoads} load" }; }}ConcurrentDictionary.GetOrAdd hands every caller the one value it
stored, so storing a Lazy<T> per key means only that instance's
factory runs. IMemoryCache.GetOrCreate gives no such guarantee: two
callers that miss together can each create and store their own Lazy,
so it needs a per-key lock to get the same effect. HybridCache has
this protection built in, within one process.
Common mistakes
- A cache key that omits an input of the query, such as the user or tenant id. The first user's data is then served to everyone who asks for the same key.
- Caching with sliding expiration only, so a frequently read entry never refreshes from the source.
- Using
IMemoryCachebehind a load balancer and invalidating on write. Only the instance that handled the write drops its copy; the others keep serving the old value until it expires. - Setting
SizeLimiton the memory cache without giving each entry aSize. Once a limit is set, an entry without a size cannot be added.
What interviewers ask
- In-memory or distributed cache for a service running on six instances? Trade-offs: consistency across instances and survival of restarts versus latency and serialisation; mention a two-level cache.
- Absolute versus sliding expiration? Definitions, and why sliding alone is risky; combine them.
- What is a cache stampede and how do you prevent it? Many concurrent
misses running the same expensive factory; per-key locking,
Lazy,HybridCache, or refreshing early.
Exercise · Pro
Stampede or shared load?
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade