.NETMedium
Caching strategies compared — in-memory vs distributed vs CDN, with .NET examples
Caching trades freshness for latency. Pick the level that matches the data's volatility and access pattern.
Five layers, lowest to highest
| Layer | Where | Latency | Capacity | Use for |
|---|---|---|---|---|
| L1 — in-process | IMemoryCache | <1 us | RAM of one node | Reference data hit thousands of times per request |
| L2 — distributed | Redis / SQL | 0.5 to 2 ms | Cluster RAM | Cross-node coherency, session state |
| L3 — output cache | [OutputCache] middleware | <1 ms | per-route | Identical responses for many users |
| L4 — CDN | CloudFront, Cloudflare | ~10 ms close to user | Massive | Static + cacheable HTML/JSON |
| L5 — client | Browser HTTP cache | 0 (no network) | per-user | Truly immutable assets (/static/abc.js?hash) |
L1 — IMemoryCache
public class PriceLookup(IMemoryCache cache, IPriceApi api) {
public Task<decimal> GetAsync(string sku) =>
cache.GetOrCreateAsync($"price:{sku}", async e => {
e.SetAbsoluteExpiration(TimeSpan.FromMinutes(5));
e.SetSize(1);
return await api.GetAsync(sku);
});
}
// Register with size limit so it does not blow memory
builder.Services.AddMemoryCache(o => o.SizeLimit = 100_000);
L2 — Redis via IDistributedCache
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = builder.Configuration.GetConnectionString("Redis"));
public async Task<User?> GetAsync(Guid id, IDistributedCache cache) {
var key = $"user:{id}";
var raw = await cache.GetStringAsync(key);
if (raw is not null) return JsonSerializer.Deserialize<User>(raw);
var user = await db.GetAsync(id);
await cache.SetStringAsync(key, JsonSerializer.Serialize(user),
new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
return user;
}
L3 — output caching (ASP.NET Core 7+)
builder.Services.AddOutputCache(o =>
o.AddPolicy("PostList", p => p.Expire(TimeSpan.FromSeconds(30))
.SetVaryByQuery("page")));
app.MapGet("/posts", () => ...).CacheOutput("PostList");
Invalidation — the hard part
"There are only two hard things in computer science: cache invalidation and naming things." — Phil Karlton
Three workable strategies:
- TTL only. Simplest. Stale data tolerated up to
T. Fine for product catalogs. - Write-through + bust. Update DB then bust the key. Coupled but coherent.
- Event-driven invalidation. Service publishes
product.updated, cache nodes subscribe. Scales to N caches without each writer knowing them.
Common mistakes
- No size limit on
IMemoryCache— OOM during peak. - Caching exceptions / nulls with the same TTL as successful lookups — stampede the upstream when it recovers.
- Cache key collisions — always include version:
user:v2:{id}so a schema change rolls forward without surprises. - Stampede — first miss after expiry produces N concurrent requests rebuild. Use
Lazy<Task<T>>orSemaphoreSlimper key.