Caching reduces database load and response latency by storing computed results in fast memory. IMemoryCache is ASP.NET Core’s in-process cache — blazing fast (nanosecond access), zero network overhead, but lost on restart and not shared between server instances. The cache-aside pattern (check cache → on miss, load from DB → store in cache → return result) is the standard approach. Understanding expiration policies and invalidation prevents the two classic caching bugs: stale data and cache stampedes.
IMemoryCache — Cache-Aside Pattern
// ── Registration ──────────────────────────────────────────────────────────
builder.Services.AddMemoryCache(opts =>
{
opts.SizeLimit = 1024; // max 1024 "size units" in cache
opts.CompactionPercentage = 0.25; // evict 25% when size limit reached
});
// ── GetOrCreateAsync — cache-aside in one call ────────────────────────────
public class PostService(IPostRepository repo, IMemoryCache cache) : IPostService
{
public async Task<PostDto?> GetBySlugAsync(string slug, CancellationToken ct)
{
var cacheKey = $"post:slug:{slug}";
return await cache.GetOrCreateAsync(cacheKey, async entry =>
{
// Configure cache entry
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.SlidingExpiration = TimeSpan.FromMinutes(5);
entry.Size = 1; // count toward SizeLimit
entry.Priority = CacheItemPriority.Normal;
// Cache miss — load from database
var post = await repo.GetBySlugAsync(slug, ct);
return post?.ToDto();
});
}
// ── Manual get/set for conditional caching ────────────────────────────
public async Task<PagedResult<PostSummaryDto>> GetPublishedPageAsync(
int page, int size, CancellationToken ct)
{
var key = $"posts:published:{page}:{size}";
if (cache.TryGetValue(key, out PagedResult<PostSummaryDto>? cached))
return cached!;
var result = await repo.GetPublishedPageAsync(page, size, ct);
var dto = result.ToPagedDto();
cache.Set(key, dto, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2),
Size = 1,
// Eviction callback for monitoring
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback = (key, value, reason, state) =>
_logger.LogDebug("Cache evicted: {Key} ({Reason})", key, reason),
}
}
});
return dto;
}
}
AbsoluteExpirationRelativeToNow sets a hard maximum TTL — the entry is evicted after this duration regardless of access. SlidingExpiration resets the TTL on every access — an entry accessed frequently stays in cache indefinitely. Use both together: SlidingExpiration evicts entries that stop being accessed, while AbsoluteExpiration guarantees eventual staleness prevention even for hot entries. A sliding-only cache can hold entries forever if they are accessed just frequently enough, leading to unbounded stale data.CacheService wrapper over IMemoryCache that encapsulates the GetOrCreateAsync pattern, standardises key naming, and adds metrics. This wrapper becomes the single point for cache configuration and monitoring — making it easy to add cache hit/miss counters, log cache performance, and change expiration policies in one place. All services inject ICacheService instead of IMemoryCache directly.IMemoryCache is not shared between server instances. In a Kubernetes deployment with 3 replicas, each has its own independent in-memory cache. A post updated on Server A is still serving the old cached version on Servers B and C until their TTLs expire. For production multi-instance deployments, either use distributed cache (Redis), accept eventual consistency with short TTLs, or implement a cache invalidation signal (Redis pub/sub) to notify all instances.CancellationToken-Based Group Invalidation
// ── Invalidate all cached pages when a post is published ──────────────────
public class PostCacheService(IMemoryCache cache) : IPostCacheService
{
// Shared token — cancelling it evicts all entries that registered it
private CancellationTokenSource _postListTokenSource = new();
public void InvalidatePostLists()
{
// Cancel the old token (evicts all entries registered with it)
_postListTokenSource.Cancel();
// Replace with new token for future cache entries
_postListTokenSource = new CancellationTokenSource();
}
public MemoryCacheEntryOptions GetPostListOptions() => new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
ExpirationTokens = { new CancellationChangeToken(_postListTokenSource.Token) }
};
}
// Usage: call InvalidatePostLists() when a post is published/deleted
Common Mistakes
Mistake 1 — Caching personalised data without user-specific keys (users see each other’s data)
❌ Wrong — caching "posts:my-feed" shared across users; User B sees User A’s feed.
✅ Correct — include user ID in cache key: $"posts:feed:{userId}".
Mistake 2 — No size limit on IMemoryCache (unbounded memory growth)
❌ Wrong — no SizeLimit configured; cache grows until OOM exception.
✅ Correct — configure SizeLimit and set Size=1 on each entry; enables eviction under memory pressure.