← 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 your API performance by like 3-5x, but if you misconfigure it, it causes memory leaks and stale data real quick. Learn some production-tested patterns for safe caching, including token-based invalidation, size limits, and when you should probably just 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 like every 4 hours.. The culprit? An unbounded IMemoryCache storing full Entity Framework entities with basically no size limit. The cache just grew and grew until the pod hit its memory ceiling and, well, OOM-killed itself.

IMemoryCache is incredibly powerful for reducing database load, but it’s not exactly a silver bullet. Used correctly it can cut response times from 400ms to under 50ms, but used poorly? it introduces subtle bugs, memory pressure, and inconsistent data. This guide covers what the official docs don’t really 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 actually prevents memory bloat, you know?

Safe Setup: Beyond AddMemoryCache()

Registering the service is trivial, but configuring it properly is where most teams honestly 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 just 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 some critical safeguards. Here’s a pattern I actually 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, just increment the version instead of trying to invalidate old entries. This avoids subtle deserialization bugs after deployments, trust me.

Expiration Strategies: Trade-Offs Explained

Choosing between sliding and absolute expiration isn’t just 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 or reference data. Never rely solely on sliding expiration for critical business data, users who step away for lunch shouldn’t trigger a massive 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, otherwise you'll feel the pain.

When NOT to Use IMemoryCache

IMemoryCache is local to each process. You should probably avoid it when:

  • ●

    Multiple Instances: K8s pods or cloud scale-out scenarios basically require distributed caching (like Redis).

  • ●

    Large Data Sets: Caching >100MB of data risks OOM kills. Just use pagination or external storage.

  • ●

    Shared State: If multiple services need consistent cached data, IMemoryCache will diverge, guaranteed.

  • ●

    Long-Lived Data: Items needed beyond app restarts should really use persistent caching.

"IMemoryCache solves latency problems, not consistency problems.. If data accuracy matters more than speed, just skip the cache."

Monitoring & Debugging Cache Health

You can’t manage what you don’t measure. You really need to implement these metrics in production:

  • ●

    Hit/Miss Ratio: Target >80% for hot paths, low ratios usually 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 you should optimize 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 just 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, you got this.

We use cookies to improve your experience.