Why caching matters in .NET interviews
Caching questions reveal whether a candidate thinks about production performance, not just correctness. Interviewers look for understanding of where to cache (in-process vs distributed), when to invalidate, and the failure modes that emerge under load — particularly cache stampede. This article covers every caching mechanism in the ASP.NET Core ecosystem.
IMemoryCache: in-process caching
IMemoryCache stores data in the process's heap. It is fast (sub-millisecond),
requires no external infrastructure, and is registered automatically in ASP.NET Core.
// Registration (included by default, but explicit is clearer):
builder.Services.AddMemoryCache(opts => opts.SizeLimit = 1024);
// GetOrCreateAsync — atomic "get or populate":
public async Task<Product?> GetProductAsync(int id)
=> await _cache.GetOrCreateAsync($"product:{id}", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.SlidingExpiration = TimeSpan.FromMinutes(2);
entry.Size = 1;
return await _db.Products.FindAsync(id);
});
// Invalidate on write:
public async Task UpdateProductAsync(Product p)
{
await _db.SaveChangesAsync();
_cache.Remove($"product:{p.Id}");
}
AbsoluteExpiration evicts after a fixed wall-clock time. SlidingExpiration
extends the deadline on each read. Combining both ensures entries never live
forever even if accessed constantly.
IMemoryCache is per-process. In a multi-server deployment, each instance has
its own cache — one server may serve stale data after another server's write
invalidates its own copy. This is the key limitation.
IDistributedCache and Redis: shared caching
IDistributedCache is an abstraction for a shared, out-of-process cache. All
application instances read and write the same store.
// Register Redis:
builder.Services.AddStackExchangeRedisCache(opts =>
{
opts.Configuration = "localhost:6379,abortConnect=false";
opts.InstanceName = "myapp:";
});
// Usage — serialize manually (unlike IMemoryCache which stores objects):
public async Task<Product?> GetProductAsync(int id)
{
var key = $"product:{id}";
var json = await _cache.GetStringAsync(key);
if (json is not null)
return JsonSerializer.Deserialize<Product>(json);
var product = await _db.Products.FindAsync(id);
if (product is null) return null;
await _cache.SetStringAsync(key, JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions
{ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
return product;
}
The serialization boilerplate motivates the cache-aside helper pattern (see below),
or the HybridCache abstraction in .NET 9 that handles this automatically.
The cache-aside pattern
Cache-aside (lazy loading) is the dominant caching pattern: check the cache, fetch from source on miss, populate the cache, return to caller.
// Generic helper eliminates per-method boilerplate:
public static async Task<T?> GetOrSetAsync<T>(
this IDistributedCache cache,
string key,
Func<Task<T?>> factory,
TimeSpan expiry)
{
var json = await cache.GetStringAsync(key);
if (json is not null) return JsonSerializer.Deserialize<T>(json);
var value = await factory();
if (value is not null)
await cache.SetStringAsync(key, JsonSerializer.Serialize(value),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = expiry });
return value;
}
// Call site:
var product = await _cache.GetOrSetAsync(
$"product:{id}",
() => _db.Products.FindAsync(id).AsTask(),
TimeSpan.FromMinutes(10));
The alternative — write-through — updates the cache on every write so reads never miss. It's more complex and adds write latency, but is useful when staleness is unacceptable.
Response caching vs output caching
These two mechanisms both cache HTTP responses but work very differently.
Response caching sets Cache-Control headers and delegates caching to the
HTTP client or a CDN/proxy:
app.UseResponseCaching();
[HttpGet("products")]
[ResponseCache(Duration = 60, VaryByQueryKeys = new[] { "page" })]
public IActionResult GetProducts(int page = 1) => Ok(_service.GetPage(page));
// Sets: Cache-Control: public, max-age=60
Output caching (.NET 7+) caches the server-rendered response in the server's own memory, bypassing the controller on hits regardless of client cache headers:
builder.Services.AddOutputCache();
app.UseOutputCache();
app.MapGet("/products", GetProducts).CacheOutput("5min");
// Tag-based invalidation — evict groups of entries atomically:
await _outputCacheStore.EvictByTagAsync("products", ct);
Output caching is strictly more powerful: it works with authenticated requests, is server-controlled, and supports tag-based invalidation. Use response caching only when you need CDN or browser caching semantics.
Cache invalidation strategies
The three main approaches for keeping caches consistent with the source of truth:
TTL (time-to-live): The simplest strategy. Entries expire after a fixed duration. Stale data is guaranteed but brief. Best for reference data (product catalog, country lists) where occasional staleness is acceptable.
Explicit removal on write: Remove the cache entry immediately when the underlying data changes. Gives you freshness at the cost of implementation effort — every write path must also remove the corresponding cache key.
public async Task UpdateProductAsync(Product p)
{
await _db.SaveChangesAsync();
await _cache.RemoveAsync($"product:{p.Id}");
await _cache.RemoveAsync("products:all"); // also bust collection caches
}
Tag-based eviction (output cache): Associate entries with semantic tags
("products", "orders:tenant-42"). Evicting a tag purges all entries in the group.
Preventing cache stampede
A cache stampede occurs when a popular entry expires and many concurrent requests all miss simultaneously, flooding the data source.
// Problem — naive cache-aside under concurrency:
if (!_cache.TryGetValue(key, out var value))
{
value = await _db.Products.ToListAsync(); // 1000 threads all hit here at once!
_cache.Set(key, value, TimeSpan.FromMinutes(5));
}
// Solution: SemaphoreSlim with double-check:
private static readonly SemaphoreSlim _lock = new(1, 1);
public async Task<IEnumerable<Product>> GetProductsAsync()
{
if (_cache.TryGetValue("products", out IEnumerable<Product>? cached))
return cached!;
await _lock.WaitAsync();
try
{
if (_cache.TryGetValue("products", out cached)) // double-check after lock
return cached!;
cached = await _db.Products.ToListAsync();
_cache.Set("products", cached, TimeSpan.FromMinutes(5));
return cached;
}
finally { _lock.Release(); }
}
In .NET 9, HybridCache.GetOrCreateAsync has stampede protection built in —
concurrent misses for the same key coalesce into a single database call.
HybridCache (.NET 9)
HybridCache combines L1 (in-process, IMemoryCache) and L2 (distributed, Redis)
in one abstraction with built-in stampede protection and automatic serialization:
builder.Services.AddHybridCache();
public async ValueTask<Product?> GetByIdAsync(int id, CancellationToken ct)
=> await _hybridCache.GetOrCreateAsync(
$"product:{id}",
async cancel => await _db.Products.FindAsync(new object[] { id }, cancel),
cancellationToken: ct);
A cache hit serves from L1 in < 1 ms. An L1 miss checks L2 before falling back to the database. Only one concurrent call per missing key reaches the database.
Rule of thumb: In .NET 9+, HybridCache is the default choice — it replaces
the manual L1 + L2 wiring pattern. For earlier versions, use IMemoryCache for
single-server deployments and IDistributedCache with a stampede guard for
multi-server deployments.