In-Memory Caching — IMemoryCache and Cache-Aside Pattern

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;
    }
}
Note: 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.
Tip: Build a 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.
Warning: 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.

🧠 Test Yourself

A cached entry has both SlidingExpiration = 5 min and AbsoluteExpiration = 10 min. It is accessed every 2 minutes. When does it expire?