.NET & C# Development · Lesson 61 of 92

Add In-Memory Cache — Cut DB Calls on Hot Endpoints

Setup

C#
// Program.cs
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 1024;           // optional: cap total cache size
    options.CompactionPercentage = 0.25; // remove 25% when limit hit
});

Inject IMemoryCache into any service:

C#
public class ProductService(IMemoryCache cache, IProductRepository repo)
{
    // ...
}

GetOrCreate — The Core Pattern

C#
public async Task<Product?> GetProductAsync(int id, CancellationToken ct = default)
{
    return await cache.GetOrCreateAsync($"product:{id}", async entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
        entry.SlidingExpiration = TimeSpan.FromMinutes(2);
        entry.Size = 1; // required if SizeLimit is set

        return await repo.FindByIdAsync(id, ct);
    });
}

GetOrCreateAsync is thread-safe for reading and setting the value, but not stampede-safe (see below).


MemoryCacheEntryOptions Reference

C#
var options = new MemoryCacheEntryOptions
{
    // Absolute: expires at this point regardless of access
    AbsoluteExpiration = DateTimeOffset.UtcNow.AddHours(1),

    // Or relative to when the entry was created
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),

    // Sliding: resets timer on each access; entry lives at most AbsoluteExpiration
    SlidingExpiration = TimeSpan.FromMinutes(2),

    // Priority: controls what gets evicted first under memory pressure
    Priority = CacheItemPriority.Normal,  // Low / Normal / High / NeverRemove

    // Size: arbitrary unit counted against SizeLimit
    Size = 1,
};

If both AbsoluteExpirationRelativeToNow and SlidingExpiration are set, the entry expires at whichever comes first.


Cache-Aside Pattern

The canonical read-through cache pattern:

C#
public class OrderService(IMemoryCache cache, IOrderRepository repo, ILogger<OrderService> logger)
{
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(3);

    public async Task<Order?> GetOrderAsync(Guid id, CancellationToken ct = default)
    {
        var cacheKey = $"order:{id}";

        // 1. Try cache first
        if (cache.TryGetValue(cacheKey, out Order? cached))
        {
            logger.LogDebug("Cache hit for order {Id}", id);
            return cached;
        }

        // 2. Miss — load from source
        logger.LogDebug("Cache miss for order {Id}", id);
        var order = await repo.FindByIdAsync(id, ct);

        // 3. Populate cache (don't cache nulls unless you want negative caching)
        if (order is not null)
        {
            cache.Set(cacheKey, order, CacheDuration);
        }

        return order;
    }

    public async Task UpdateOrderAsync(Order order, CancellationToken ct = default)
    {
        await repo.UpdateAsync(order, ct);

        // Invalidate on write
        cache.Remove($"order:{order.Id}");
    }
}

Eviction Callbacks

React when an entry is removed — useful for logging, metrics, or warming a replacement:

C#
var options = new MemoryCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
};

options.RegisterPostEvictionCallback((key, value, reason, state) =>
{
    var logger = (ILogger)state!;
    logger.LogInformation(
        "Cache entry {Key} evicted. Reason: {Reason}",
        key, reason);
    // reason: Expired | Capacity | Removed | Replaced | TokenExpired
}, logger);

cache.Set("config:feature-flags", featureFlags, options);

Cache Expiry Tokens — Expire Multiple Entries Together

C#
// Create a shared cancellation token source
var cts = new CancellationTokenSource();
var expiryToken = new CancellationChangeToken(cts.Token);

// Apply to multiple entries
cache.Set("product:1", product1, new MemoryCacheEntryOptions()
    .AddExpirationToken(expiryToken));
cache.Set("product:2", product2, new MemoryCacheEntryOptions()
    .AddExpirationToken(expiryToken));

// Expire all at once
cts.Cancel(); // both entries evicted simultaneously

Useful for cache tags before HybridCache existed — but HybridCache has built-in tag invalidation (see that article).


The Cache Stampede Problem

GetOrCreateAsync is NOT safe against stampedes. Under high concurrency, when the entry expires, many threads simultaneously find a cache miss and all call the factory function together.

Thread 1: TryGetValue → miss → calling factory...
Thread 2: TryGetValue → miss → calling factory...  ← same key, concurrent!
Thread 3: TryGetValue → miss → calling factory...  ← all hitting the DB

Fix: SemaphoreSlim Per Key

C#
public class StampedeResistantCache(IMemoryCache cache)
{
    // One semaphore per cache key
    private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

    public async Task<T?> GetOrCreateAsync<T>(
        string key,
        Func<CancellationToken, Task<T?>> factory,
        TimeSpan duration,
        CancellationToken ct = default)
    {
        // Fast path — cache hit, no lock needed
        if (cache.TryGetValue(key, out T? cached))
            return cached;

        var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));

        await semaphore.WaitAsync(ct);
        try
        {
            // Double-check after acquiring lock
            if (cache.TryGetValue(key, out cached))
                return cached;

            var value = await factory(ct);

            if (value is not null)
                cache.Set(key, value, duration);

            return value;
        }
        finally
        {
            semaphore.Release();
            _locks.TryRemove(key, out _);
        }
    }
}

The double-check inside the lock prevents the second thread from calling the factory after the first has already populated the cache.

Note: HybridCache (.NET 9) has stampede protection built in — consider using it for new projects.


Negative Caching

Cache misses (null results) to avoid hammering the database for known non-existent records:

C#
// Sentinel object to represent a known miss
private static readonly object NullSentinel = new();

public async Task<Product?> GetProductAsync(int id, CancellationToken ct = default)
{
    var key = $"product:{id}";

    if (cache.TryGetValue(key, out object? raw))
    {
        return raw == NullSentinel ? null : (Product?)raw;
    }

    var product = await repo.FindByIdAsync(id, ct);

    var options = new MemoryCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = product is null
            ? TimeSpan.FromSeconds(30)  // short TTL for negative cache
            : TimeSpan.FromMinutes(5)
    };

    cache.Set(key, product ?? NullSentinel, options);

    return product;
}

When IMemoryCache Is NOT Enough

| Situation | Problem | Solution | |---|---|---| | Multiple app instances (load balancer) | Each instance has its own cache — inconsistent data | IDistributedCache (Redis) | | Cache must survive restarts | In-memory cache is wiped on restart | IDistributedCache (Redis) | | Very large datasets | Memory pressure causes evictions | Redis | | Need tag-based invalidation | IMemoryCache has no tag support | HybridCache (.NET 9) | | Cache in background service + web | Different DI scopes | Singleton IMemoryCache or Redis |

Single-instance apps, short-lived frequently-read data (config, lookups, computed aggregates) — IMemoryCache is perfect.


Key Takeaways

  • AddMemoryCache() registers a singleton IMemoryCache — safe to inject everywhere
  • Always set expiration; never cache without it
  • Use SlidingExpiration for frequently-accessed hot data, AbsoluteExpiration for data with a known freshness limit
  • Combine both: sliding resets on access, absolute ensures eventual expiry
  • Use SemaphoreSlim double-check locking to prevent cache stampedes
  • Multi-instance deployments need distributed cache — IMemoryCache is per-process