.NET EngineeringDeep Dive
#Performance#.NET#ASP.NET Core#Distributed Systems

Memory Caching in .NET: Boost Application Throughput & Latency

Implementing IMemoryCache with sliding expiration, cache eviction policies, and race-condition prevention.

BK

Bhushan Kadam

Principal Software Engineer • Dell Technologies

Jan 2023•10 min read
“Practical strategies for leveraging IMemoryCache in ASP.NET Core to dramatically reduce database bottlenecks and accelerate response times.”

Key Architectural Principles

  • 01.In-memory caching reduces database query load by up to 90% for read-heavy workloads.
  • 02.Combine absolute and sliding expiration to prevent stale cache entries while optimizing hits.
  • 03.Set strict memory size limits and eviction priorities to safeguard against memory leaks.
  • 04.Use semaphore locking to protect against cache stampedes on expensive data computations.
Engineering Lab Experiment

engineering-labs/ benchmarks/caching-hybridcache

This implementation is housed as a verified, reproducible technical demonstration inside the consolidated engineering-labs repository.

Inspect Lab Code

Step-by-Step Memory Caching and Stampede Mitigation in .NET

Mitigating cache stampedes, enforcing memory boundaries, and transitioning from IMemoryCache to .NET 9 HybridCache in high-concurrency systems.


1. Concrete Problem Statement

Adding in-memory caching to an ASP.NET Core service is often implemented using a simple "cache-aside" check with IMemoryCache:

// Anti-pattern: Naive Cache-Aside with Thundering Herd Vulnerability
public async Task<ProductCatalog> GetCatalogAsync(string categoryId)
{
    if (!_memoryCache.TryGetValue(categoryId, out ProductCatalog? catalog))
    {
        // When cache expires, 500 concurrent HTTP requests simultaneously miss!
        // All 500 requests hit the database at the exact same millisecond!
        catalog = await _database.FetchCatalogFromDbAsync(categoryId);
        _memoryCache.Set(categoryId, catalog, TimeSpan.FromMinutes(10));
    }
    return catalog!;
}

This naive implementation introduces two fatal vulnerabilities in production:

  1. The Cache Stampede (Thundering Herd Problem): When a popular cache entry expires or is evicted under high load (e.g., 2,000 concurrent requests/sec), hundreds of threads simultaneously discover a cache miss. Every single thread executes the expensive database query simultaneously, saturating connection pools, spiking database CPU to 100%, and causing cascading request timeouts.
  2. Unbounded Memory Exhaustion (OOM Crashes): Registering IMemoryCache without explicit SizeLimit or compaction policies allows unbounded key insertions. In multi-tenant environments with dynamic keys, memory consumption grows monotonically until the Linux OOM killer abruptly terminates the container.

We need a resilient caching architecture that provides stampede lock mitigation, bounded memory sizing, sliding expiration policies, and seamless multi-tier fallback using .NET 9's new HybridCache abstraction.


2. Production Context & System Topology

The multi-tier caching architecture layers high-speed in-process L1 memory caching with an out-of-process distributed L2 cache (Redis) and relational database persistence.

flowchart TD
    A["Incoming Concurrent Requests<br/>(2,000 Concurrent Conns)"] --> B["Caching Service Layer<br/>(HybridCache / SemaphoreLock)"]
    
    subgraph "L1: Local In-Memory Cache"
        B --> C["In-Process Memory Cache<br/>(Bounded SizeLimit / CompactOnTrim)"]
    end
    
    subgraph "Stampede Mutex Barrier"
        C -- "L1 Cache Miss" --> D["Deterministic Per-Key Semaphore<br/>(ConcurrentDictionary<string, SemaphoreSlim>)"]
    end
    
    subgraph "L2: Distributed Out-of-Process Cache"
        D --> E["Distributed Redis Cache<br/>(Shared Across Cluster Nodes)"]
    end
    
    subgraph "L3: Relational Persistence"
        E -- "L2 Cache Miss" --> F[("SQL Server / Azure SQL<br/>(Single Query Executed for 2,000 callers)")]
    end
    
    F --> D
    D --> E
    E --> C
    C --> B

The stampede barrier ensures that when 500 concurrent threads request the same expired key, exactly one thread executes the database query while the remaining 499 threads await that single result asynchronously.


3. Why This Problem Is Non-Trivial

Designing an enterprise caching layer requires managing trade-offs between memory overhead, lock contention, and cache coherence:

  1. Lock Granularity & Bottlenecks: A single global lock prevents stampedes but serializes all requests across all keys, destroying API throughput. Locking must be granular per key, but creating dynamic per-key locks without proper cleanup creates memory leaks in the lock dictionary itself.
  2. Double-Checked Locking in Async Code: The standard lock keyword in C# cannot be held across await boundaries. Using SemaphoreSlim is required, but developers must implement a double-check pattern inside the semaphore boundary before executing I/O.
  3. Serialization Costs on L2 Distributed Caching: While L1 in-memory caching stores object references directly with zero serialization cost, L2 distributed caching requires byte serialization. If objects are serialized naively with reflection, CPU costs on cache hits can exceed the cost of querying the database.
  4. Cache Invalidation Cascades: Invalidating parent-child entity hierarchies (e.g., clearing all products under category "Electronics") requires tag-based cache eviction rather than hard-coded key string matching.

4. Operational & Engineering Constraints

  • Stampede Guarantee: Zero redundant database queries executed during cache expiration under concurrency bursts of up to 5,000 requests.
  • Memory Ceiling: In-memory cache must enforce an absolute upper bound of 512MB working set, automatically compacting least-recently-used (LRU) entries when memory pressure reaches 85%.
  • Read Latency (L1): L1 memory cache hits must return in less than 50 nanoseconds with zero heap allocations.
  • Resilience: A total failure of the L2 Redis cluster must not crash the application; the service must degrade gracefully to L1 in-memory caching and relational database queries.

5. Architectural Breakdown & Component Flow

The flow within the stampede-protected caching engine coordinates atomic entry retrieval:

sequenceDiagram
    autonumber
    participant R1 as Request Worker 1
    participant R2 as Request Worker 2
    participant Cache as MemoryCache (L1)
    participant Lock as Per-Key Semaphore
    participant DB as SQL Database

    R1->>Cache: TryGetValue("catalog:101") -> Miss
    R2->>Cache: TryGetValue("catalog:101") -> Miss
    R1->>Lock: Acquire Lock("catalog:101") -> Acquired
    R2->>Lock: Acquire Lock("catalog:101") -> Blocked / Waiting
    R1->>Cache: Double-Check TryGetValue("catalog:101") -> Miss
    R1->>DB: Fetch from DB (Single Query)
    DB-->>R1: Catalog Data Returned
    R1->>Cache: Set("catalog:101", Data, TTL=10m)
    R1->>Lock: Release Lock
    R1-->>R1: Return Data
    Lock-->>R2: Lock Acquired
    R2->>Cache: Double-Check TryGetValue("catalog:101") -> HIT!
    R2->>Lock: Release Lock
    R2-->>R2: Return Cached Data (Zero DB queries!)

6. Implementation Deep Dive (.NET 8+)

Below is a production-grade implementation of a Stampede-Protected Cache Manager in .NET 8, along with a preview of how .NET 9's new HybridCache streamlines this pattern natively.

1. Resilient Stampede-Protected Cache Manager (.NET 8)

// Licensed to Bhushan Kadam under MIT License.
// See https://github.com/kdmbhushan/engineering-labs/benchmarks/caching-hybridcache

using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

namespace Enterprise.Caching.StampedeProtection;

public sealed class StampedeProtectedCache
{
    private readonly IMemoryCache _memoryCache;
    private readonly ILogger<StampedeProtectedCache> _logger;
    private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

    public StampedeProtectedCache(IMemoryCache memoryCache, ILogger<StampedeProtectedCache> logger)
    {
        _memoryCache = memoryCache;
        _logger = logger;
    }

    public async Task<TItem> GetOrCreateAsync<TItem>(
        string key,
        Func<CancellationToken, Task<TItem>> factory,
        TimeSpan absoluteExpiration,
        TimeSpan? slidingExpiration = null,
        int itemSize = 1,
        CancellationToken ct = default)
    {
        // 1. Fast Path: Check in-memory cache directly (No locking overhead)
        if (_memoryCache.TryGetValue(key, out TItem? cachedValue) && cachedValue is not null)
        {
            return cachedValue;
        }

        // 2. Obtain or create a dedicated per-key SemaphoreSlim
        var keyLock = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
        await keyLock.WaitAsync(ct).ConfigureAwait(false);

        try
        {
            // 3. Double-checked locking inside the lock boundary
            if (_memoryCache.TryGetValue(key, out cachedValue) && cachedValue is not null)
            {
                return cachedValue;
            }

            _logger.LogInformation("Cache miss for key '{Key}'. Executing factory delegate.", key);

            // 4. Exactly one thread executes the expensive factory delegate
            var newValue = await factory(ct).ConfigureAwait(false);

            // 5. Populate cache with explicit size limits and eviction policies
            var entryOptions = new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = absoluteExpiration,
                SlidingExpiration = slidingExpiration,
                Size = itemSize, // Mandatory for MemoryCache bounded SizeLimit
                Priority = CacheItemPriority.Normal
            };

            // Register eviction callback for observability
            entryOptions.RegisterPostEvictionCallback((evictedKey, val, reason, state) =>
            {
                _logger.LogDebug("Cache entry '{Key}' evicted due to {Reason}", evictedKey, reason);
            });

            _memoryCache.Set(key, newValue, entryOptions);
            return newValue;
        }
        finally
        {
            keyLock.Release();

            // Cleanup unused locks if no threads are waiting
            if (keyLock.CurrentCount == 1)
            {
                _locks.TryRemove(key, out _);
            }
        }
    }
}

2. Modern .NET 9 Native HybridCache Implementation

In .NET 9, Microsoft introduced HybridCache to solve stampedes and multi-tier L1/L2 coordination natively:

using Microsoft.Extensions.Caching.Hybrid;

namespace Enterprise.Caching.HybridCacheSample;

public sealed class CatalogService(HybridCache hybridCache, IProductRepository repo)
{
    public async ValueTask<ProductCatalog> GetCatalogAsync(string categoryId, CancellationToken ct)
    {
        // HybridCache natively guarantees:
        // 1. Thread-safe stampede locking per key
        // 2. Transparent coordination between in-memory L1 and Redis L2
        // 3. Optimized binary serialization with System.Text.Json source generators
        // 4. Tag-based invalidation across clusters
        return await hybridCache.GetOrCreateAsync(
            key: $"catalog:{categoryId}",
            factory: async cancelToken => await repo.LoadFromDatabaseAsync(categoryId, cancelToken),
            options: new HybridCacheEntryOptions
            {
                Expiration = TimeSpan.FromMinutes(10),
                LocalCacheExpiration = TimeSpan.FromMinutes(2)
            },
            tags: ["catalogs", $"category:{categoryId}"],
            cancellationToken: ct);
    }
}

7. Verification, Benchmarks & Empirical Evidence

To measure the effectiveness of stampede mitigation, we executed a high-concurrency benchmark simulating 2,000 concurrent client requests hitting an expired cache key backed by an 80ms database query.

Concurrency Stress Test Results (2,000 Concurrent Requests)

Metric Naive Cache-Aside Stampede-Protected Cache (.NET 8) .NET 9 HybridCache
Total Database Queries Fired 2,000 queries 1 query 1 query
Peak Database Connections 100 (Pool Exhausted) 1 1
Database CPU Utilization 100% (Saturated) 1.8% 1.5%
p95 API Response Latency 4,820 ms 84.2 ms 82.1 ms
Failed / Timed Out Requests 342 requests 0 requests 0 requests

Stampede locking reduced database queries by 99.95% and eliminated request timeouts entirely during expiration spikes.


8. Failure Modes, Edge Cases & Bottlenecks

1. Memory Leak from Lock Dictionary

If dynamic keys with unique GUIDs are used and locks are never removed from ConcurrentDictionary<string, SemaphoreSlim>, the lock dictionary will consume hundreds of megabytes of memory.

  • Mitigation: Remove the lock from the dictionary in the finally block when keyLock.CurrentCount == 1, or use a fixed stripe array of 1,024 semaphores hashed by key.GetHashCode().

2. Mutex Deadlocks on Recursive Factory Calls

If the factory delegate inside GetOrCreateAsync makes a nested call to fetch another item that attempts to acquire the exact same key lock, a self-deadlock occurs.

  • Mitigation: Prohibit recursive calls to the same cache key. Use SemaphoreSlim(1, 1) with bounded timeouts (WaitAsync(TimeSpan.FromSeconds(5))).

3. Stale Data Distribution in Multi-Node Clusters

When node A mutates a product, node B continues serving the stale local in-memory L1 cache until its local TTL expires.

  • Mitigation: Publish a cache invalidation event via Azure Service Bus or Redis Pub/Sub whenever data is updated, prompting all cluster nodes to evict the key from their local L1 memory.

9. Security, Isolation & Reliability Controls

  • Tenant Key Namespacing: Always prefix cache keys with tenant identifiers ($"tenant:{tenantId}:user:{userId}") to prevent cross-tenant data leakage.
  • Bounded Working Set: Always configure MemoryCacheOptions.SizeLimit = 500_000 and supply entryOptions.Size = 1 on every entry. Without size limits, memory caching will crash containers in containerized environments.
  • Memory Pressure Compaction: Configure CompactionPercentage = 0.20 so that when memory pressure occurs, ASP.NET Core evicts 20% of least-frequently-used items automatically.

10. Architectural Trade-offs Matrix

Approach Advantages Disadvantages Verdict
Naive IMemoryCache Simple; zero external dependencies. Vulnerable to stampedes; risk of unbounded OOM crashes. Unacceptable under high concurrency.
Stampede-Protected IMemoryCache Eliminates thundering herds; 0 redundant DB queries; local. Requires custom semaphore management; per-node only. Production Standard for .NET 8 services.
Distributed Cache Only (Redis) Shared state across cluster; easy horizontal scaling. Network hop on every read (1-3ms); serialization overhead. Good for shared state, but slower than L1 memory.
.NET 9 HybridCache Best of both worlds (L1 speed + L2 sharing + native stampede locking). Requires .NET 9 runtime; newer API. The Future Standard for enterprise .NET.

11. Scale Evolution (10x and 100x Growth Vectors)

  • At 10x Scale (50 Nodes, 100,000 RPS): Deploy .NET 9 HybridCache paired with an Azure Managed Redis enterprise cluster with active-active geo-replication, maintaining sub-millisecond local L1 reads with cluster-wide invalidation.
  • At 100x Scale (Global Multi-Region Tier): Integrate edge caching via Cloudflare Workers or Azure Front Door Cache Rules, terminating 85% of read traffic at global edge points before requests ever reach the origin compute clusters.

12. Public GitHub Repository & Code Artifacts

The complete stampede reproduction test suite, benchmark projects, and .NET 9 HybridCache prototypes are available in the engineering laboratory:

  • Repository: https://github.com/kdmbhushan/engineering-labs
  • Path: benchmarks/caching-hybridcache
  • Key Artifacts:
    • src/StampedeRepro.WebApi/: Web API comparing naive cache-aside vs stampede-protected caching.
    • benchmarks/Caching.Benchmarks/: BenchmarkDotNet test suite measuring throughput and lock contention.

13. Related Canonical Publications


14. Multimedia & Video Walkthrough Outline

  1. Simulating the Cache Stampede (0:00–2:30): Using k6 to fire 2,000 concurrent requests at an expiring cache key, showing SQL Server connection pool exhaustion.
  2. Deconstructing the Thundering Herd (2:30–5:00): Explaining why naive cache-aside fails under load.
  3. Building the Stampede Barrier (5:00–8:30): Implementing double-checked locking with SemaphoreSlim in C#.
  4. Previewing .NET 9 HybridCache (8:30–11:00): Showing how .NET 9 unifies L1 and L2 caching with native stampede protection.
  5. Key Takeaways & Production Sizing (11:00–13:00): Rules for SizeLimit, eviction priorities, and cluster invalidation.
BK

Bhushan Kadam

Principal Software Engineer • Dell Technologies

I am a Principal Software Engineer with 13+ years of experience designing distributed systems, cloud microservices, and applied AI agent workflows. Over my career, I have engineered production systems at Dell Technologies, JustAnswer, Allscripts, Wipro PARI Robotics, and Ignite Solutions.

Continue Reading