← Back to Articles
.NET 8+ ASP.NET Core Performance In-Memory Caching Backend Architecture Production Best Practices

IMemoryCache in ASP.NET Core: Production Patterns, Pitfalls, and When to Avoid It

IMemoryCache can boost API performance by 3-5x, but misconfiguration causes memory leaks and stale data. Learn production-tested patterns for safe caching, including token-based invalidation, size limits, and when to choose Redis instead.

Jun 2026
14 min read
Written by Engineering Team

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.

Request flow with IMemoryCache showing cache hit/miss paths
A cache hit bypasses the database entirely, but only if your eviction policy prevents memory bloat.

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:

Program.cs
csharp
              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:

ProductService.cs
csharp
              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?
Use change tokens via `IChangeToken` or publish events through a message bus. For simple cases, versioned keys (e.g., "products:v3") let you "invalidate" by incrementing the version on next deploy.
Can I cache async operations safely?
Yes, but always pass `CancellationToken` and call `entry.Abort()` on cancellation. Otherwise, canceled tasks may cache incomplete results.
What’s the maximum safe cache size?
Keep cache under 20% of available container memory. For a 2GB pod, limit cache to ~400MB. Monitor actual usage—serialized objects often consume 3-5x their raw size.
Should I cache authentication tokens?
Never cache sensitive tokens in IMemoryCache. Use secure, encrypted distributed stores with strict TTLs. Memory caches are vulnerable to process dumps.

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.

We use cookies to improve your experience.