IMemoryCache — In-Process Caching in ASP.NET Core
IMemoryCache in depth: registration, absolute and sliding expiration, cache entry options, size limits, eviction callbacks, and the production patterns for safe in-process caching.
What IMemoryCache Is
IMemoryCache stores cached values in the process memory of the running application. It is fast (no network, no serialization), simple to set up, and perfect for single-instance applications or caching values that differ per instance.
IMemoryCache:
Storage: process memory (RAM on the current server)
Speed: nanoseconds (dictionary lookup)
Lifetime: process lifetime — cache lost on restart/redeploy
Sharing: NOT shared across multiple instances
When to use:
✓ Single-instance apps or Azure App Service with 1 instance
✓ Values that do not need consistency across instances
✓ Small-to-medium datasets (do not fill your process memory)
✓ Reference data: drug formulary, hospital code lookups
✗ Do not use when 2+ instances must share stateRegistration
// Program.cs
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 1000; // optional: cap the number of entries
// Without SizeLimit, no eviction based on count — only expiration
});Basic Get/Set
public sealed class DrugFormularyCache
{
private readonly IMemoryCache _cache;
private readonly DrugRepository _repo;
public DrugFormularyCache(IMemoryCache cache, DrugRepository repo)
=> (_cache, _repo) = (cache, repo);
public async Task<List<Drug>> GetFormularyAsync(CancellationToken ct)
{
// GetOrCreateAsync: get if exists, create if not — atomically
return await _cache.GetOrCreateAsync("formulary", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
entry.SlidingExpiration = TimeSpan.FromMinutes(15);
entry.Priority = CacheItemPriority.High;
return await _repo.GetAllActiveAsync(ct);
}) ?? new List<Drug>();
}
}Expiration Types
Absolute expiration:
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
Entry expires 1 hour after it was created
Good for: data that changes on a schedule (nightly refresh)
Sliding expiration:
entry.SlidingExpiration = TimeSpan.FromMinutes(15)
Entry expires if not accessed for 15 minutes
Resets on each access
Good for: user-session data, per-user preferences
Combined (recommended):
AbsoluteExpirationRelativeToNow = 1 hour
SlidingExpiration = 15 minutes
Entry expires after 1 hour regardless, or 15 min of inactivity
Prevents cache entries from living forever via constant accessSize Limit and Eviction
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 500; // max 500 size units
});
// Each entry must declare its size when SizeLimit is set
_cache.GetOrCreateAsync("formulary", entry =>
{
entry.Size = 1; // counts as 1 unit toward the 500 limit
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
// ...
});
// Priority affects which entries are evicted first when memory pressure hits
entry.Priority = CacheItemPriority.High; // evict last
entry.Priority = CacheItemPriority.Normal; // default
entry.Priority = CacheItemPriority.Low; // evict first
entry.Priority = CacheItemPriority.NeverRemove; // never evicted by pressureEviction Callbacks
// Called when an entry is evicted — useful for logging or refresh
entry.RegisterPostEvictionCallback((key, value, reason, state) =>
{
var logger = (ILogger)state!;
logger.LogInformation(
"Cache entry {Key} evicted due to {Reason}",
key, reason);
// reason: Expired, Replaced, Capacity, Removed
});Manual Get and Set
// Check if exists
if (_cache.TryGetValue("formulary", out List<Drug>? cached))
return cached!;
// Set manually
_cache.Set("formulary", drugs, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});
// Remove manually (invalidation)
_cache.Remove("formulary");Stampede Protection (Cache Miss Thundering Herd)
// Problem: 100 concurrent requests all miss the cache simultaneously
// All 100 hit the database at the same time
// Solution: lock or use SemaphoreSlim to allow only one to build
private static readonly SemaphoreSlim _lock = new(1, 1);
public async Task<List<Drug>> GetFormularyAsync(CancellationToken ct)
{
if (_cache.TryGetValue("formulary", out List<Drug>? cached))
return cached!;
await _lock.WaitAsync(ct);
try
{
// Double-check after acquiring lock
if (_cache.TryGetValue("formulary", out cached))
return cached!;
var drugs = await _repo.GetAllActiveAsync(ct);
_cache.Set("formulary", drugs, TimeSpan.FromHours(1));
return drugs;
}
finally
{
_lock.Release();
}
}Production issue I've seen: A drug formulary endpoint had a 500ms DB query. At shift-change time (7 AM), 200 nurses logged in within 60 seconds, the cache expired at the same moment, and all 200 requests hit the database simultaneously. The database CPU spiked to 100% for 2 minutes.
SemaphoreSlimprotection (or HybridCache) prevents this entirely.
Caching Patterns for Clinical Data
Drug formulary:
AbsoluteExpiration: 4 hours (formulary updated by pharmacy team, not real-time)
Key: "formulary:{hospitalId}"
Invalidate: when pharmacy team saves a formulary change
Ward lookup:
AbsoluteExpiration: 24 hours (wards rarely change)
Key: "wards"
Patient in current session:
SlidingExpiration: 10 minutes (evict if nurse walks away)
Key: "patient:{patientId}:session:{sessionId}"
Never cache diagnosis or drug orders — too critical to serve staleRed Flag / Green Answer
Red Flag: "We cache everything in IMemoryCache in our load-balanced environment with 4 instances."
Each instance has its own in-process cache. An update on instance A invalidates its cache but not instances B, C, or D. Users on different instances see different data. Use IDistributedCache (Redis) for consistency across instances.
Green Answer:
IMemoryCache for reference data that can be stale for an hour (drug formulary, ICD codes). Redis (IDistributedCache or HybridCache) for any data that must be consistent across all instances.
Key Takeaway
IMemoryCacheis the fastest cache option: process-local, no serialization, nanosecond access. Use it for reference data in single-instance apps or data that can be stale per-instance. Set absolute AND sliding expiration together to bound entry lifetime. Add stampede protection viaSemaphoreSlimor migrate to HybridCache (which has built-in stampede protection). Never use IMemoryCache for cross-instance consistency — that requires a distributed cache.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.