ASP.NET Core Caching — Why ResponseCache Leaks User Data
ResponseCache with Location=Any served one user's prices to others.
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- ASP.NET Core offers three caching layers: IMemoryCache (in-process), IDistributedCache (Redis/SQL), and Response Caching (HTTP middleware)
- IMemoryCache is fastest but isolated per server — use only in single-server deployments
- IDistributedCache with Redis provides shared state across multiple instances at the cost of a network hop
- Response Caching caches full HTTP responses before your controller runs — ideal for public, anonymous endpoints
- All caching must pair sliding expiration with an absolute cap to avoid immortal stale data
- Biggest mistake: using IMemoryCache in a load-balanced cluster, leading to inconsistent data across servers
Caching in ASP.NET Core is a performance optimization technique that stores frequently accessed data in a fast-access layer to avoid redundant computation or database queries. It exists because I/O operations (database calls, API requests, file reads) are orders of magnitude slower than memory access — a single database round trip can take 10-50ms, while an in-memory cache hit is sub-microsecond.
The ecosystem offers three primary caching approaches: in-memory caching via IMemoryCache (single-server, fast but not shared), distributed caching via IDistributedCache (multi-server, using Redis, SQL Server, or NCache), and response caching at the HTTP middleware layer (caches entire HTTP responses based on headers like Cache-Control). The critical distinction is that response caching operates at the HTTP level — it caches the full response including headers and body — while in-memory and distributed caches store arbitrary objects you control explicitly.
Where most teams go wrong is treating ResponseCache as a simple performance knob without understanding its security implications. The [ResponseCache] attribute and middleware cache responses by URL and query string, meaning if User A visits /account/details and User B visits the same URL, User B gets User A's cached response — a classic cross-user data leak.
This is fundamentally different from IMemoryCache or IDistributedCache, where you control cache keys and can include user identity (e.g., user_123_account_details). Response caching is only safe for truly public, unauthenticated content — think static assets, public product listings, or CDN-friendly resources.
For any user-specific data, you must use the lower-level caching APIs with explicit key scoping.
When NOT to use response caching: any endpoint that returns user-specific data, requires authentication, or varies by user role. Instead, use IMemoryCache for single-server apps (e.g., a small internal tool serving 100 users) or IDistributedCache for multi-server deployments (e.g., a SaaS app behind a load balancer).
Real-world numbers: Redis-backed distributed caching can handle 100,000+ operations per second on a modest instance, while in-memory caching on a single server is limited by RAM and CPU. The two-level caching pattern (L1 in-memory, L2 distributed) gives you the speed of local memory with the consistency of a shared store — but adds complexity around invalidation.
Cache invalidation strategies (time-based expiry, sliding expiration, event-driven removal via pub/sub or database change tracking) are where most production bugs live; a stale cache serving old data is often worse than no cache at all.
Imagine a librarian who, instead of walking to the back storeroom every time you ask for the same popular book, keeps a copy right at their desk. The first request is slow — they have to fetch it — but every request after that is instant. Caching in ASP.NET Core is exactly that librarian. Your app 'remembers' expensive results — database queries, API calls, computed values — and hands them back instantly for repeat requests. The trick is knowing when the book at the desk is too old and needs replacing.
Every millisecond your API spends fetching the same database row it fetched three seconds ago is a millisecond wasted — and under load, those milliseconds stack up into seconds that cost you users. Caching is not a micro-optimisation; it is the difference between an app that collapses under real traffic and one that scales gracefully. High-traffic systems like e-commerce product pages, news feeds, and dashboards owe most of their performance not to faster hardware, but to well-designed caches.
The core problem caching solves is the cost of repetition. Database queries, HTTP calls to third-party APIs, and complex in-memory computations all take time proportional to their complexity — not proportional to how often you call them. Without caching, a product page hit 10,000 times a minute fires 10,000 identical SQL queries. With caching, it fires one, and returns the stored result for the other 9,999. The challenge — and the reason most developers get caching wrong — is deciding what to cache, for how long, and when to throw it away.
By the end of this article you will understand the three main caching layers available in ASP.NET Core (In-Memory, Distributed, and Response), know exactly which one to reach for in a given situation, and be able to implement each with production-grade patterns including cache-aside, sliding expiration, and cache invalidation. You will also walk away knowing the mistakes that silently destroy cache effectiveness in real apps.
Why ResponseCache Leaks User Data
ASP.NET Core caching stores frequently accessed data in memory or distributed stores to reduce redundant processing. The core mechanic is simple: after the first request, the response is saved and served directly for subsequent identical requests, bypassing controller logic and database calls. This is not a silver bullet — it introduces statefulness into a stateless HTTP pipeline.
Key properties: cache duration (absolute expiration), cache key (typically URL + query string), and cache scope (in-memory vs distributed). The critical nuance is that ResponseCache works at the middleware level — it caches the entire HTTP response, including headers. If your endpoint returns user-specific data (e.g., 'Welcome, Alice'), the next user hitting the same URL gets 'Welcome, Alice' too. This is not a bug; it's a feature misapplied.
Use ResponseCache only for truly public, anonymous data — product listings, static content, or API responses that are identical for all users. Never apply it to authenticated endpoints or any response that varies by user identity. The moment you cache a per-user response, you've introduced a cross-user data leak that is silent, consistent, and hard to debug.
In-Memory Caching with IMemoryCache — Fast, Simple, Single-Server
In-Memory caching stores data directly in the RAM of your web server process. It is the fastest cache available because there is zero network round-trip — the data lives in the same memory space as your application. ASP.NET Core exposes this through the IMemoryCache interface, which you register once and inject anywhere.
The pattern you will use 99% of the time is called cache-aside (also known as lazy loading): you try to get the value from cache first; if it is not there (a 'cache miss'), you fetch it from the real source, store it in cache, then return it. On the next call, you get a 'cache hit' and skip the expensive work entirely.
In-memory cache is the right choice when you have a single server deployment or when the cached data is local to one server (like a per-user preferences object). It is the wrong choice when you run multiple server instances behind a load balancer — because each server has its own isolated cache, and a user hitting Server A might get stale data that Server B already updated. That is the scenario where distributed caching becomes essential.
MemoryCache entries support both absolute expiration (evict after exactly N minutes) and sliding expiration (evict if nobody reads it for N minutes). Use sliding expiration for 'warm' data that is accessed frequently; use absolute for data that must stay fresh regardless of traffic.
using Microsoft.Extensions.Caching.Memory; using System; using System.Threading.Tasks; // Register IMemoryCache in Program.cs: // builder.Services.AddMemoryCache(); public class ProductService { private readonly IMemoryCache _cache; private readonly IProductRepository _repository; // Cache key constants prevent typos across the codebase private const string ProductCacheKeyPrefix = "product_"; private static readonly TimeSpan ProductCacheDuration = TimeSpan.FromMinutes(10); public ProductService(IMemoryCache cache, IProductRepository repository) { _cache = cache; _repository = repository; } public async Task<Product?> GetProductByIdAsync(int productId) { // Build a unique key per product so we can invalidate one without flushing all string cacheKey = $"{ProductCacheKeyPrefix}{productId}"; // TryGetValue returns true on a cache HIT — we skip the database entirely if (_cache.TryGetValue(cacheKey, out Product? cachedProduct)) { Console.WriteLine($"[CACHE HIT] Returning product {productId} from memory cache."); return cachedProduct; } // Cache MISS — go to the real data source Console.WriteLine($"[CACHE MISS] Fetching product {productId} from database."); Product? product = await _repository.GetByIdAsync(productId); if (product is not null) { // Configure cache entry options before storing var cacheOptions = new MemoryCacheEntryOptions() // Evict this entry if it hasn't been accessed in 5 minutes (sliding) .SetSlidingExpiration(TimeSpan.FromMinutes(5)) // But always evict after 10 minutes regardless of access (absolute) .SetAbsoluteExpiration(ProductCacheDuration) // Mark as normal priority — the runtime can evict under memory pressure .SetPriority(CacheItemPriority.Normal); _cache.Set(cacheKey, product, cacheOptions); Console.WriteLine($"[CACHE SET] Product {productId} stored in memory cache."); } return product; } // Call this when a product is updated so the next read fetches fresh data public void InvalidateProductCache(int productId) { string cacheKey = $"{ProductCacheKeyPrefix}{productId}"; _cache.Remove(cacheKey); Console.WriteLine($"[CACHE INVALIDATED] Removed product {productId} from cache."); } } // --- Simulated output for two sequential calls to GetProductByIdAsync(42) --- // First call: // [CACHE MISS] Fetching product 42 from database. // [CACHE SET] Product 42 stored in memory cache. // // Second call (within 5 minutes): // [CACHE HIT] Returning product 42 from memory cache.
GetCurrentStatistics() to monitor cache size and evictions.Distributed Caching with IDistributedCache — Sharing State Across Multiple Servers
When you scale your app horizontally — multiple instances behind a load balancer — in-memory cache breaks down because each instance has its own isolated memory. User A might update a record on Server 1, but User B hits Server 2 which still has the old cached version. This is a consistency bug, not just a performance issue.
Distributed caching solves this by putting the cache outside the application in a shared store — typically Redis or SQL Server. All instances read from and write to the same cache, so everyone sees the same data. ASP.NET Core abstracts this behind IDistributedCache, meaning you can swap Redis for SQL Server (or vice versa) by changing one line in Program.cs without touching your service code.
Redis is the industry standard choice. It is an in-memory data store purpose-built for speed, supporting complex data types, pub/sub for cache invalidation, and cluster mode for high availability. SQL Server distributed cache exists for environments where you already have SQL infrastructure and cannot add Redis — but it is meaningfully slower.
The IDistributedCache API works with byte arrays, so you need to serialise your objects. The standard approach is JSON serialisation with System.Text.Json. A cleaner pattern is to wrap IDistributedCache in your own generic helper that handles serialisation transparently — which is exactly what the example below does.
// Program.cs registration (Redis example): // builder.Services.AddStackExchangeRedisCache(options => // { // options.Configuration = builder.Configuration.GetConnectionString("Redis"); // options.InstanceName = "MyApp:"; // Prefix all keys to avoid collisions // }); // // For SQL Server instead, use: // builder.Services.AddDistributedSqlServerCache(options => { ... }); using Microsoft.Extensions.Caching.Distributed; using System.Text.Json; using System.Threading; using System.Threading.Tasks; // A generic wrapper that hides the byte-array pain of IDistributedCache public class DistributedCacheService { private readonly IDistributedCache _distributedCache; public DistributedCacheService(IDistributedCache distributedCache) { _distributedCache = distributedCache; } // Returns cached value or null on a miss — caller decides what to do public async Task<T?> GetAsync<T>(string cacheKey, CancellationToken cancellationToken = default) where T : class { byte[]? cachedBytes = await _distributedCache.GetAsync(cacheKey, cancellationToken); if (cachedBytes is null || cachedBytes.Length == 0) { return null; // Cache miss } // Deserialise from JSON bytes back to the strongly-typed object return JsonSerializer.Deserialize<T>(cachedBytes); } public async Task SetAsync<T>( string cacheKey, T value, TimeSpan absoluteExpiration, CancellationToken cancellationToken = default) where T : class { byte[] serialisedBytes = JsonSerializer.SerializeToUtf8Bytes(value); var cacheEntryOptions = new DistributedCacheEntryOptions { // Absolute expiration from now — always evict after this window AbsoluteExpirationRelativeToNow = absoluteExpiration }; await _distributedCache.SetAsync(cacheKey, serialisedBytes, cacheEntryOptions, cancellationToken); } public async Task RemoveAsync(string cacheKey, CancellationToken cancellationToken = default) { await _distributedCache.RemoveAsync(cacheKey, cancellationToken); } } // --- Usage in a controller or service --- public class OrderSummaryService { private readonly DistributedCacheService _cache; private readonly IOrderRepository _orderRepository; private static readonly TimeSpan OrderSummaryCacheDuration = TimeSpan.FromMinutes(15); public OrderSummaryService(DistributedCacheService cache, IOrderRepository orderRepository) { _cache = cache; _orderRepository = orderRepository; } public async Task<OrderSummary?> GetOrderSummaryAsync(int customerId, CancellationToken cancellationToken) { string cacheKey = $"order_summary_{customerId}"; // Step 1: Try the distributed cache first OrderSummary? cached = await _cache.GetAsync<OrderSummary>(cacheKey, cancellationToken); if (cached is not null) { Console.WriteLine($"[REDIS HIT] Order summary for customer {customerId} served from Redis."); return cached; } // Step 2: Cache miss — hit the database Console.WriteLine($"[REDIS MISS] Querying database for customer {customerId} order summary."); OrderSummary? summary = await _orderRepository.GetSummaryByCustomerIdAsync(customerId, cancellationToken); // Step 3: Store in Redis for the next caller — all server instances benefit if (summary is not null) { await _cache.SetAsync(cacheKey, summary, OrderSummaryCacheDuration, cancellationToken); Console.WriteLine($"[REDIS SET] Customer {customerId} summary cached for 15 minutes."); } return summary; } }
Response Caching — Cache at the HTTP Layer Before Your Code Even Runs
In-Memory and Distributed caching are application-level caches — your C# code still runs and decides whether to call the database. Response Caching works at a completely different layer: it caches the entire HTTP response and serves it directly from middleware, before your controller action ever executes. This is the fastest possible cache because zero application code runs on a cache hit.
Response caching follows HTTP caching semantics via Cache-Control headers. When your action returns a response with Cache-Control: public, max-age=60, both the ASP.NET Core response cache middleware (server-side) and downstream proxies or CDNs (like Cloudflare or Azure CDN) know they can cache and replay that response for 60 seconds.
This makes it ideal for public, anonymous content: marketing pages, product catalogues, news articles — content that is the same for every user. It is completely wrong for authenticated or personalised content because cached responses ignore who the user is. A response cached for User A would be served to User B.
The [ResponseCache] attribute controls the Cache-Control header. The AddResponseCaching() middleware does the actual server-side caching. Both are needed for server-side caching to work — the attribute alone just sets the header for downstream caches like CDNs.
// Program.cs — register and use the middleware (ORDER MATTERS): // builder.Services.AddResponseCaching(); // ... // app.UseResponseCaching(); // Must come before app.MapControllers() using Microsoft.AspNetCore.Mvc; using System.Collections.Generic; using System.Threading.Tasks; [ApiController] [Route("api/[controller]")] public class CatalogueController : ControllerBase { private readonly ICatalogueService _catalogueService; public CatalogueController(ICatalogueService catalogueService) { _catalogueService = catalogueService; } // This response is cached for 60 seconds server-side AND tells CDNs they can cache it too. // 'public' means any cache (server, CDN, proxy) can store this response. // 'VaryByQueryKeys' means separate cache entries are kept per 'category' query param // so /api/catalogue?category=shoes and ?category=hats each get their own cached version. [HttpGet] [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "category" })] public async Task<IActionResult> GetProductsAsync([FromQuery] string category = "all") { Console.WriteLine($"[CONTROLLER HIT] Fetching products for category: {category}"); // This line only runs on a cache MISS — during a 60-second window it runs once per category IEnumerable<ProductSummary> products = await _catalogueService.GetByCategoryAsync(category); return Ok(products); } // [ResponseCache(NoStore = true)] explicitly opts this endpoint OUT of caching. // Use this for any endpoint that returns user-specific or sensitive data. [HttpGet("my-orders")] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public async Task<IActionResult> GetMyOrdersAsync() { // Each call always hits the controller — never cached var orders = await _catalogueService.GetOrdersForCurrentUserAsync(); return Ok(orders); } } // HTTP Response headers produced by the first endpoint: // Cache-Control: public,max-age=60 // Vary: Accept-Encoding // // Subsequent requests within 60 seconds return: // [Served from response cache — controller action does NOT execute] // // HTTP Response headers produced by the second endpoint: // Cache-Control: no-store,no-cache
UseResponseCaching() is not registered in your middleware pipeline. The Cache-Control header will still be sent (which CDNs honour), but the server-side middleware won't vary by query string — so all category requests return the first cached response regardless of the parameter.UseResponseCaching() is after MapControllers(), caching never happens.UseResponseCaching() and rely on the [ResponseCache] attribute to set proper Cache-Control headers for downstream caches.Cache Invalidation Strategies — Knowing When to Throw Data Away
Writing to cache is easy. Knowing when to evict is where production systems break. The three main strategies are: time-based expiry, explicit key removal, and pattern-based invalidation.
Time-based expiry is the simplest. You set a TTL (absolute or sliding) and let the cache purge entries automatically. The danger? If all entries for a popular endpoint expire at the same moment, every request triggers a database call — the 'thundering herd' problem. Add random jitter to expiration times (e.g., base ± 10%) to spread the load.
Explicit key removal means calling Remove or RemoveAsync when the underlying data changes. Works well for individual records, but breaks down when one data change invalidates many cache keys (e.g., updating a category name that appears in 1000 product entries). For these cases, use a key prefix pattern: store all keys with a shared prefix, and when invalidating, iterate over a separate index of keys (like a Redis SET) to evict them all.
Pattern-based invalidation using Redis sets: maintain a SET of cache keys for each 'tag' (e.g., 'category:shoes'). When the shoes category is updated, retrieve all keys from the set and delete them. This gives you bulk invalidation without a full cache flush.
For distributed systems across multiple servers, use Redis Pub/Sub: when one server invalidates a key, publish a message. All other servers subscribe and evict their local (L1) copy of that key, ensuring consistency.
using StackExchange.Redis; using Microsoft.Extensions.Caching.Distributed; using System.Text.Json; public class CacheInvalidationService { private readonly IDistributedCache _cache; private readonly IConnectionMultiplexer _redis; public CacheInvalidationService(IDistributedCache cache, IConnectionMultiplexer redis) { _cache = cache; _redis = redis; } // Invalidate a single key public async Task InvalidateKeyAsync(string cacheKey) { await _cache.RemoveAsync(cacheKey); // Notify other servers to evict their local L1 cache var subscriber = _redis.GetSubscriber(); await subscriber.PublishAsync("cache:invalidation", cacheKey); } // Invalidate all keys belonging to a tag (requires a Redis Set of keys per tag) public async Task InvalidateTagAsync(string tag) { var db = _redis.GetDatabase(); // Get all keys that belong to this tag (e.g., "tag:category:shoes") string tagKey = $"tag:{tag}"; RedisValue[] memberKeys = await db.SetMembersAsync(tagKey); // Remove each key from the distributed cache var tasks = memberKeys.Select(k => _cache.RemoveAsync(k.ToString())); await Task.WhenAll(tasks); // Publish for L1 eviction on other servers var subscriber = _redis.GetSubscriber(); await subscriber.PublishAsync("cache:invalidation:tag", tag); // Remove the tag set itself await db.KeyDeleteAsync(tagKey); } // Associate a cache key with a tag during Set public async Task SetWithTagAsync<T>(string cacheKey, T value, TimeSpan expiration, string tag) { // Store in distributed cache as usual byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(value); await _cache.SetAsync(cacheKey, bytes, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = expiration }); // Add the key to the tag's Redis Set var db = _redis.GetDatabase(); await db.SetAddAsync($"tag:{tag}", cacheKey); } } // Usage: // await invalidationService.SetWithTagAsync("product:42", product, TimeSpan.FromMinutes(10), "category:shoes"); // ... later, when the shoes category changes: // await invalidationService.InvalidateTagAsync("category:shocks"); // invalidates all related keys
- A product price update should invalidate only that product's keys — not the entire product catalogue.
- A category name change might affect hundreds of product keys — use tags to link them.
- A promotion activation might invalidate many unrelated categories — use pub/sub to broadcast a tag-based eviction.
- Avoid global flush: it causes a cold cache and instant database meltdown.
Remove() right after the database write.Two-Level Caching (L1/L2) — Combining IMemoryCache and IDistributedCache for Speed and Consistency
A two-level cache sits IMemoryCache (L1) in front of IDistributedCache (L2). On a read request, you check the local in-memory cache first (fastest, zero network). On a miss, you check the distributed cache (Redis). On a distributed cache hit, you populate L1 so subsequent requests on that server are instant. On a distributed cache miss, you fetch from the database and store in both L2 and L1.
This pattern dramatically reduces Redis round-trips for hot data. In production systems with read-heavy workloads, two-level caching cuts p95 latency by 80-90% compared to using distributed cache alone. The cost is added complexity in invalidation: when a write occurs, you must evict the key from L2 and also from L1 on all servers. Use Redis pub/sub to broadcast L1 eviction commands.
Implementation steps: wrap both caches in a single service that follows the order: L1 → L2 → DB. Use a memory cache region per server (e.g., by server name) to avoid serialisation clashes. Monitor hit ratios at both levels — a low L1 hit rate means your memory cache is too small or your data isn't local enough.
One trap: if you store objects in L1 that are also in L2, ensure you're not holding references that prevent garbage collection. Use weak references or smaller L1 sizes for objects that change frequently.
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Distributed; using System.Text.Json; public class TwoLevelCacheService { private readonly IMemoryCache _localCache; // L1 private readonly IDistributedCache _distCache; // L2 public TwoLevelCacheService(IMemoryCache localCache, IDistributedCache distCache) { _localCache = localCache; _distCache = distCache; } public async Task<T?> GetAsync<T>(string cacheKey, Func<Task<T?>> fetchFromDb, CancellationToken ct = default) where T : class { // Try L1 (local memory) first if (_localCache.TryGetValue(cacheKey, out T? localResult) && localResult is not null) { return localResult; } // L1 miss — try L2 (distributed, e.g., Redis) byte[]? distBytes = await _distCache.GetAsync(cacheKey, ct); if (distBytes is not null && distBytes.Length > 0) { T? deserialized = JsonSerializer.Deserialize<T>(distBytes); if (deserialized is not null) { // Populate L1 for future near-instant access _localCache.Set(cacheKey, deserialized, TimeSpan.FromMinutes(5)); // sliding for L1 return deserialized; } } // L2 miss — fetch from database T? result = await fetchFromDb(); if (result is null) return null; // Store in both caches byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(result); var distOptions = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }; await _distCache.SetAsync(cacheKey, bytes, distOptions, ct); _localCache.Set(cacheKey, result, TimeSpan.FromMinutes(5)); return result; } // Invalidate from both caches and broadcast to other servers public async Task InvalidateAsync(string cacheKey, IConnectionMultiplexer redis, CancellationToken ct = default) { _localCache.Remove(cacheKey); await _distCache.RemoveAsync(cacheKey, ct); var subscriber = redis.GetSubscriber(); await subscriber.PublishAsync("cache:l1:evict", cacheKey); } } // --- Usage --- // var product = await twoLevelCache.GetAsync("product:42", // () => _repo.GetByIdAsync(42), cancellationToken);
GetCurrentStatistics(). If it drops below 50%, increase L1 size or adjust entry priorities.Cache Stampede — The Silent Performance Killer in .NET
You've implemented caching. Great. Now your app is blazing fast until a cached key expires and a thousand requests all try to rebuild it simultaneously. That's a cache stampede. The database melts under the load spike. Users get timeouts. Your on-call phone rings at 2 AM.
A cache stampede happens when multiple requests hit the same expired or missing cache key at once. Each request computes the expensive operation instead of waiting for the first one to finish. This nullifies your caching benefit and spikes resource usage.
The fix is simple: coordinate cache rebuilding so that only one thread computes the value while others wait. .NET's IMemoryCache.GetOrCreate does this internally with a lock-free pattern. For distributed caches, you need explicit locking or a hybrid approach.
Never assume your cache invalidation is safe under concurrency. If you don't handle stampedes, your 'performance improvement' becomes a reliability disaster.
// io.thecodeforge — csharp tutorial using Microsoft.Extensions.Caching.Memory; public class PricingService { private readonly IMemoryCache _cache; private readonly IProductRepository _repository; public PricingService(IMemoryCache cache, IProductRepository repository) { _cache = cache; _repository = repository; } public async Task<decimal> GetPriceAsync(int productId) { string key = $"price:{productId}"; // GetOrCreate synchronizes access using a semaphore internally return await _cache.GetOrCreateAsync(key, async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); entry.SlidingExpiration = TimeSpan.FromMinutes(2); return await _repository.FetchProductPriceAsync(productId); }); } }
HybridCache — .NET 9's Answer to Cache Stampede and L1/L2 Hell
Distributed caches (Redis, SQL Server) protect your database but introduce latency and network cost. Local caches (IMemoryCache) are fast but cause cold-start storms and state inconsistency across nodes. .NET 9's HybridCache solves this by combining both: an in-process L1 cache for sub‑microsecond reads and a shared L2 cache for durability. More importantly, it bakes in cache stampede prevention. When the L1 entry expires and multiple requests arrive simultaneously, only one thread rebuilds the value; the rest wait on the same async operation. This eliminates thundering herds without manual locking or serialization hacks. HybridCache also handles reconnection to L2 stores and provides a unified API that abstracts cache backends behind a single interface. You register it via AddHybridCache and configure the default entry timeouts and serialization. The result: lower latency, higher throughput, and significantly simpler production caching code.
// io.thecodeforge — csharp tutorial using Microsoft.Extensions.Caching.Hybrid; // Register in Program.cs builder.Services.AddHybridCache(options => { options.DefaultEntryOptions = new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(5), LocalCacheExpiration = TimeSpan.FromSeconds(30) }; }); // In service layer public async Task<Product> GetProductAsync(int id) { var cacheKey = $"product:{id}"; return await _cache.GetOrCreateAsync<Product>( cacheKey, async cancel => await _db.Products.FindAsync(new object[] { id }, cancel), cancellationToken: CancellationToken.None ); }
Quick Comparison — Which Cache Should You Burn Your Deploy on?
You don't pick a cache because it's trendy. You pick it because your architecture demands it. In-memory is fastest. Period. 10-100x faster than Redis because there's zero network hop. But it dies with the process, so your multi-server farm gets cache drift. Distributed cache fixes drift but introduces serialization cost and latency. Response caching is the cheat code — it never touches your application code for static resources. But you must lock down user-specific headers or you leak data. Two-level caching tries to have it all: memory speed plus distributed durability. It works until you fight cache stampede. Then you need HybridCache (.NET 9) which wraps stampede protection and L1/L2 orchestration into a single API. Pick distributed for session state across scale sets. Pick in-memory for reference data that rarely changes. Pick response for static assets behind a CDN. Pick HybridCache for everything else. Your database will thank you.
// io.thecodeforge — csharp tutorial // Quick mental model for cache selection public enum CacheChoice { InMemory, // Single server, fast, volatile Distributed, // Multi-server, consistent, slower Response, // HTTP level, static content, auth trap TwoLevel, // Fast + consistent, stampede risk HybridCache // .NET 9: stampede-safe L1/L2 wrapper } // Real-world heuristic if (servers == 1 && data.IsReference) UseInMemory(); else if (servers > 1 && data.IsUserSession) UseDistributed(); else if (data.IsStatic || data.IsPublic) UseResponseCaching(); else if (dotNET9Available) UseHybridCache();
Compatibility — What .NET Versions Won't Gut Your Cache Code
IMemoryCache and IDistributedCache ship in Microsoft.Extensions.Caching.Abstractions since .NET Core 2.0. That's stable, boring, and won't break when you upgrade. Response caching middleware has been stable since .NET Core 3.0 — but .NET 5 added the [ResponseCache] attribute location constraints. If you target .NET Framework 4.7.2, you're stuck with System.Runtime.Caching.ObjectCache. It works but lacks the async API. HybridCache is .NET 9 only. Do not backport it. It depends on new locking primitives in the runtime. SQL Server and Redis distributed cache providers target standard 2.0 through 8.0 — no surprises. The trap is serialization. BinaryFormatter was deprecated in .NET 5 and removed in .NET 8. If you serialize complex objects with BinaryFormatter, your cache warm-up will throw at startup. Switch to System.Text.Json or MessagePack now. Your future self will thank you when the production cluster rolls over without a cache-miss storm.
// io.thecodeforge — csharp tutorial // Safe serialization for distributed cache across .NET versions var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // .NET 8 removed BinaryFormatter — use JSON or MessagePack }; var data = new CachePayload { UserId = 42, Role = "Viewer" }; var json = JsonSerializer.SerializeToUtf8Bytes(data, options); // Store in Redis — works on .NET 6, 7, 8, 9 await cache.SetAsync("user:42", json); // Retrieve — no breaking changes across minor versions var cached = await cache.GetAsync("user:42"); var result = JsonSerializer.Deserialize<CachePayload>(cached, options); Console.WriteLine(result.Role); // Output: Viewer
Personalised Prices Served to Wrong Users — Response Caching Breaks Authentication
- Never use response caching on endpoints that return user-specific or authenticated data.
- Always explicitly opt out of caching for personalised endpoints with [ResponseCache(NoStore = true)].
- Review all [ResponseCache] attributes during code review — one wrong attribute can leak data across users.
SetAbsoluteExpiration() to enforce a hard limit.UseResponseCaching() to be registered. Without it, the middleware ignores the Vary header and caches only the first URL. Also ensure middleware is placed before app.MapControllers().Add logging: `Console.WriteLine($"Cache key: {key}, hit: {_cache.TryGetValue(key, out var val)}");`Check MemoryCache statistics: `_cache.GetCurrentStatistics()` (available in .NET 6+)._cache.Set() on cache miss and that cache options don't have zero expiration.`redis-cli -h <host> -p <port> ping` should return PONG.`dotnet-counters monitor --counters Microsoft.AspNetCore.Hosting` to see active connections and requests.options.ConfigurationOptions.ConnectTimeout = 5000; options.ConfigurationOptions.SyncTimeout = 5000;`curl -I http://localhost:5000/api/endpoint | grep -i cache`Check middleware order: ensure app.UseResponseCaching() appears before app.MapControllers() in Program.cs.[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)] on the endpoint and verify middleware is registered.| Aspect | IMemoryCache (In-Memory) | IDistributedCache (Redis/SQL) | Response Caching |
|---|---|---|---|
| Where data lives | Server RAM (in-process) | External store (Redis/SQL) | Server RAM (HTTP response bytes) |
| Works across multiple servers | No — each server has its own cache | Yes — all servers share one store | Partially — server-side no; CDN yes |
| What gets cached | Any C# object | Any serialisable object | Entire HTTP response |
| Cache logic location | Your service/repository code | Your service/repository code | Middleware — before controller runs |
| Serialisation required | No — stores live objects | Yes — must serialise to bytes/JSON | No — stores raw HTTP response |
| Best for | Single-server or small apps | Horizontally scaled APIs | Public, anonymous HTTP endpoints |
| Worst for | Multi-server deployments | Frequently changing data | Authenticated or personalised endpoints |
| Setup complexity | Low — one line registration | Medium — needs Redis or SQL Server | Low — middleware + attribute |
| Relative speed | Fastest (in-process RAM) | Fast (but network round-trip to Redis) | Fastest for matched requests |
| File | Command / Code | Purpose |
|---|---|---|
| ProductService.cs | using Microsoft.Extensions.Caching.Memory; | In-Memory Caching with IMemoryCache |
| DistributedCacheService.cs | using Microsoft.Extensions.Caching.Distributed; | Distributed Caching with IDistributedCache |
| CatalogueController.cs | using Microsoft.AspNetCore.Mvc; | Response Caching |
| CacheInvalidationService.cs | using StackExchange.Redis; | Cache Invalidation Strategies |
| TwoLevelCacheService.cs | using Microsoft.Extensions.Caching.Memory; | Two-Level Caching (L1/L2) |
| CacheStampedeFix.cs | using Microsoft.Extensions.Caching.Memory; | Cache Stampede |
| HybridCacheExample.cs | using Microsoft.Extensions.Caching.Hybrid; | HybridCache |
| CacheComparison.cs | public enum CacheChoice | Quick Comparison |
| CompatibilityCheck.cs | var options = new JsonSerializerOptions | Compatibility |
Key takeaways
Common mistakes to avoid
3 patternsCaching EF Core entities instead of DTOs
Using IMemoryCache in a multi-server (load-balanced) deployment
Setting only SlidingExpiration with no AbsoluteExpiration cap
SetSlidingExpiration() with SetAbsoluteExpiration() to guarantee a maximum staleness window regardless of access frequency.Interview Questions on This Topic
What is the difference between IMemoryCache and IDistributedCache in ASP.NET Core, and what specific scenario would force you to switch from one to the other?
Explain the cache-aside pattern. Why is it preferred over always writing to the cache on every database write, and what are its consistency trade-offs?
If Response Caching is configured correctly but cached responses are not being served for requests with query strings, what is the most likely cause and how would you diagnose it?
UseResponseCaching() missing), or the middleware is placed after MapControllers() in the pipeline. Without the middleware, the Vary header is still sent but the server-side cache doesn't actually vary by query string — it serves the first cached response for all query variants. To diagnose, check HTTP response headers: if Cache-Control: public,max-age=60 is present but the same response is returned for different query strings, the middleware is missing or misordered. Also verify that the middleware is added with builder.Services.AddResponseCaching() and used with app.UseResponseCaching() before app.MapControllers().Frequently Asked Questions
AddMemoryCache registers IMemoryCache — a true in-process memory cache tied to the server's RAM. AddDistributedMemoryCache registers an IDistributedCache implementation that also uses in-process memory, but behind the distributed cache interface. It exists purely for local development and testing so you can code against IDistributedCache without needing a real Redis server running. Never use AddDistributedMemoryCache in production — it has the same multi-server isolation problem as IMemoryCache.
For IMemoryCache call _cache.Remove(cacheKey) immediately after your database update succeeds. For IDistributedCache call await _distributedCache.RemoveAsync(cacheKey). The cleanest architecture is to invalidate in the same service method that performs the write — update the database, then evict the cache key — so the next read triggers a fresh fetch. For complex scenarios with many related keys, use a cache key prefix strategy or Redis tag-based invalidation patterns.
Yes, and this is actually a common production pattern called a two-level (L1/L2) cache. You check IMemoryCache first (L1 — fastest, no network), and only on a miss do you check IDistributedCache (L2 — Redis). On an L2 hit, you populate L1 so the next request on that same server is instant. This reduces Redis round-trips significantly under high read traffic while keeping multi-server consistency intact.
For IMemoryCache use GetCurrentStatistics() (available in .NET 6+) to get hit/miss counts. For Redis, use the INFO stats command to see keyspace_hits and keyspace_misses. For Response Caching, enable middleware logging or inspect the diagnostic events: add app. and check for log messages indicating cache hits or misses. The CacheCore middleware emits events you can subscribe to for metrics.UseResponseCaching()
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
That's ASP.NET. Mark it forged?
8 min read · try the examples if you haven't