IMemoryCache — Fast In-Process Caching in ASP.NET Core
Cache expensive results in-process with IMemoryCache. Covers GetOrCreate, expiration policies, cache stampede prevention with SemaphoreSlim, eviction callbacks, and when to move to distributed cache.
Setup
// 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:
public class ProductService(IMemoryCache cache, IProductRepository repo)
{
// ...
}GetOrCreate — The Core Pattern
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
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:
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:
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
// 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 simultaneouslyUseful 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 DBFix: SemaphoreSlim Per Key
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:
// 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 singletonIMemoryCache— safe to inject everywhere- Always set expiration; never cache without it
- Use
SlidingExpirationfor frequently-accessed hot data,AbsoluteExpirationfor data with a known freshness limit - Combine both: sliding resets on access, absolute ensures eventual expiry
- Use
SemaphoreSlimdouble-check locking to prevent cache stampedes - Multi-instance deployments need distributed cache —
IMemoryCacheis per-process
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.