All articles

C# Interview Guide: Caching Layers — IMemoryCache, IDistributedCache, HybridCache, Output Cache

2 min read
#0
0 reads
0 reading now
0 shares

Q19 — Caching Layers — IMemoryCache, IDistributedCache, HybridCache, Output Cache

Level: Senior | Topic: Performance Optimization

Choosing the Right Cache

  • IMemoryCache: In-process, zero serialisation, microsecond latency. Single-instance only — cache is lost on restart and not shared across pods.
  • IDistributedCache (Redis): Shared across all instances, survives restarts. Requires serialisation. Use for multi-instance deployments.
  • HybridCache (.NET 9): L1 (in-memory) + L2 (Redis) with built-in stampede protection. The modern default for multi-instance apps.
  • Output Cache (.NET 7+): Caches the full HTTP response at the middleware level. Tag-based invalidation, vary by route/header. No application code needed per endpoint.

Code Example

// 1. IMemoryCache — single node, zero serialisation
var products = await _memCache.GetOrCreateAsync("products:all", async entry =>
{
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
    entry.SlidingExpiration               = TimeSpan.FromMinutes(3);
    entry.Priority                        = CacheItemPriority.High;
    return await repo.GetAllAsync(ct);
}) ?? [];

// 2. IDistributedCache (Redis) — multi-instance
var key = $"user:{userId}";
var bytes = await _distCache.GetAsync(key, ct);
if (bytes is not null)
    return JsonSerializer.Deserialize<UserDto>(bytes);

var user = await repo.GetByIdAsync(userId, ct);
await _distCache.SetAsync(key,
    JsonSerializer.SerializeToUtf8Bytes(user),
    new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
    }, ct);

// 3. HybridCache (.NET 9) — L1 memory + L2 Redis + stampede protection
var product = await _hybridCache.GetOrCreateAsync(
    $"product:{id}",
    async token => await _repo.GetByIdAsync(id, token),
    new HybridCacheEntryOptions
    {
        Expiration            = TimeSpan.FromMinutes(30),  // L2 Redis TTL
        LocalCacheExpiration  = TimeSpan.FromMinutes(5)    // L1 Memory TTL
    },
    tags: [$"product", $"product:{id}"],   // tag-based invalidation
    cancellationToken: ct);

// Invalidate by tag (removes from both L1 and L2)
await _hybridCache.RemoveByTagAsync($"product:{id}", ct);

// 4. Output Cache (Program.cs) — response-level caching
builder.Services.AddOutputCache(opts =>
{
    opts.AddBasePolicy(b => b.Expire(TimeSpan.FromSeconds(30)));
    opts.AddPolicy("ByUserId", b => b.VaryByRouteValue("userId").Expire(TimeSpan.FromMinutes(5)));
});
app.UseOutputCache();

// On controller action:
[HttpGet, OutputCache(PolicyName = "ByUserId", Tags = new[] { "user" })]
public async Task<IActionResult> GetUser(string userId) { ... }

Senior Insight

Cache stampede (dog-pile) is when a cache expiry causes dozens of concurrent requests to all hit the database simultaneously. HybridCache handles this automatically. With IMemoryCache, implement a SemaphoreSlim-based locking pattern manually. Always define TTLs conservatively — stale data bugs are harder to debug than cache misses. Use tag-based invalidation to purge related cache entries atomically when an entity is updated.

ShareLinkedInX

Comments

Share your thoughts without signing in.