Introduction: The Double-Edged Sword of In-Memory Caching
I once debugged a production outage where an ASP.NET Core API crashed every 4 hours. The culprit? An unbounded IMemoryCache storing full Entity Framework entities with no size limit. The cache grew until the pod hit its memory ceiling and OOM-killed itself.
IMemoryCache is incredibly powerful for reducing database load, but it’s not a silver bullet. Used correctly, it can cut response times from 400ms to <50ms. Used poorly, it introduces subtle bugs, memory pressure, and inconsistent data. This guide covers what the official docs don’t tell you: how to safely implement caching in high-traffic .NET applications.

Safe Setup: Beyond AddMemoryCache()
Registering the service is trivial, but configuring it properly is where most teams fail. Always set a size limit and default expiration to prevent runaway memory usage:
builder.Services.AddMemoryCache(options =>
{
// CRITICAL: Prevent unbounded growth
options.SizeLimit = 1024; // Arbitrary units, not bytes
// Set sensible defaults so forgotten entries don't linger
options.ExpirationScanFrequency = TimeSpan.FromMinutes(2);
});
Why SizeLimit Matters: Without it, a single hot endpoint can consume all available memory. The SizeLimit is in arbitrary units—you define what "1 unit" means per cached item (see next section).
Production-Ready Caching Pattern
The basic GetOrCreateAsync example in docs omits critical safeguards. Here’s a pattern I use in production that handles cancellation, normalizes keys, and assigns meaningful sizes:
public async Task<List<ProductDto>> GetProductsAsync(
CancellationToken cancellationToken = default)
{
const string cacheKey = "products:all:v2"; // Versioned keys!
return await _cache.GetOrCreateAsync(
cacheKey,
async entry =>
{
// Assign size based on expected object graph
entry.Size = 10;
// Use sliding + absolute expiration combo
entry.SlidingExpiration = TimeSpan.FromMinutes(5);
entry.AbsoluteExpirationRelativeToNow =
TimeSpan.FromHours(1);
try
{
var products = await _repository
.GetProductsAsync(cancellationToken);
// Cache DTOs, NEVER entities
return products.Select(p => new ProductDto(
p.Id, p.Name, p.Price)).ToList();
}
catch (OperationCanceledException)
{
// Don't cache canceled operations
entry.Abort();
throw;
}
});
}
💡 Key Versioning Pro Tip
Always include a version suffix in cache keys (e.g., "v2"). When you change the data shape or serialization format, increment the version instead of trying to invalidate old entries. This avoids subtle deserialization bugs after deployments.
Expiration Strategies: Trade-Offs Explained
Choosing between sliding and absolute expiration isn’t academic—it directly impacts user experience and system stability:
| Strategy | Best For | Risk |
|---|---|---|
| Absolute Only | Static reference data (countries, currencies) | Stale data if source changes unexpectedly |
| Sliding Only | User sessions, personalized content | Memory bloat from rarely-accessed items |
| Combined (Recommended) | Most business data | Slightly more complex configuration |
My Rule of Thumb: Use sliding expiration for user-facing data (so active users always get fresh results) and absolute expiration for background/reference data. Never rely solely on sliding expiration for critical business data—users who step away for lunch shouldn’t trigger a cache miss storm when they return.
Realistic Performance Expectations
Benchmarks vary wildly based on data size, serialization, and hardware. Here are results from a recent production migration (Azure B2s VM, SQL Server):**
| Metric | Before Cache | After IMemoryCache | Notes |
|---|---|---|---|
| P95 Response Time | 420 ms | 85 ms | Includes serialization overhead |
| DB Queries/sec | 180 | 22 | 87% reduction |
| Memory Usage | 1.2 GB | 1.8 GB | +600MB for cache |
| CPU During Warm-up | 35% | 65% | Brief spike on cache population |
Critical Caveat: The first request after deployment or cache eviction will be slow. Always pre-warm critical caches during startup or use a background service to populate them asynchronously.
When NOT to Use IMemoryCache
IMemoryCache is local to each process. Avoid it when:
- ●
Multiple Instances: K8s pods or cloud scale-out scenarios require distributed caching (Redis).
- ●
Large Data Sets: Caching >100MB of data risks OOM kills. Use pagination or external storage.
- ●
Shared State: If multiple services need consistent cached data, IMemoryCache will diverge.
- ●
Long-Lived Data: Items needed beyond app restarts should use persistent caching.
"IMemoryCache solves latency problems, not consistency problems. If data accuracy matters more than speed, skip the cache."
Monitoring & Debugging Cache Health
You can’t manage what you don’t measure. Implement these metrics in production:
- ●
Hit/Miss Ratio: Target >80% for hot paths. Low ratios indicate wrong TTLs or key design.
- ●
Memory Pressure Events: Log when cache evicts items due to size limits.
- ●
Eviction Reasons: Track whether items expire naturally or are force-evicted.
- ●
Serialization Time: High CPU during cache writes suggests optimizing DTOs.
Use IMemoryCacheStatistics (available in .NET 8+) or custom middleware to expose these metrics to Prometheus/Grafana.
Frequently Asked Questions
How do I invalidate cache when data changes?
Can I cache async operations safely?
What’s the maximum safe cache size?
Should I cache authentication tokens?
Conclusion
IMemoryCache is a precision tool, not a blunt instrument. Configure size limits, use combined expiration strategies, cache DTOs only, and monitor hit rates relentlessly. When implemented correctly, it transforms API performance—but always validate whether distributed caching better suits your architecture.
Next steps: Read our guides on [Redis Distributed Caching Patterns] and [ASP.NET Core Performance Profiling] to complete your caching strategy.
