Skip to main content

Command Palette

Search for a command to run...

7 Common ASP.NET Core Caching Mistakes (And How to Fix Them)

Updated
β€’11 min read
7 Common ASP.NET Core Caching Mistakes (And How to Fix Them)

Caching is one of the highest-leverage tools in an ASP.NET Core developer's toolkit β€” but it is also one of the most misused. The gap between "we added caching" and "our caching actually helps" is wider than most teams expect. In production systems, poorly applied caching can cause stale data bugs, thundering herd failures, memory pressure, and subtle data consistency issues that are notoriously difficult to debug. The full, working implementation with edge cases, cache invalidation logic, and production-ready configuration is available on Patreon β€” with annotated source code that maps directly to what enterprise teams actually ship.

Understanding Chapter 9 of the Zero to Production course β€” which covers IMemoryCache, IDistributedCache, HybridCache, and output caching in the same chapter, all wired together inside a full production API β€” helps these concepts click in ways that isolated examples rarely do.

ASP.NET Core Web API: Zero to Production

This article walks through seven caching mistakes that appear repeatedly in real ASP.NET Core codebases β€” and what to do instead.


Mistake 1: Using IMemoryCache in a Multi-Instance Deployment

The most widespread caching mistake in ASP.NET Core is deploying IMemoryCache in a horizontally scaled environment without understanding what it means. IMemoryCache is a per-process, in-memory store. When you run three instances of your API behind a load balancer, each instance has its own cache. A write on instance A does not propagate to instances B or C. Users hitting different pods get different data β€” and you have an invisible consistency problem.

The fix: Reserve IMemoryCache for data that is truly static for the lifetime of a process (configuration-derived lookups, compiled templates, read-only reference data). For anything that changes and must be consistent across instances, use IDistributedCache with a Redis backend β€” or better, reach for HybridCache (.NET 9+), which gives you a two-layer L1/L2 cache that keeps the per-process speed advantage while using distributed storage as the authoritative source.


Mistake 2: Ignoring the Cache Stampede Problem

A cache stampede β€” sometimes called a thundering herd β€” occurs when a cached value expires and multiple concurrent requests all miss the cache at the same moment. Each request independently executes the expensive operation (a database query, an external API call) to populate the cache. In a high-traffic API, a single expiration event can trigger hundreds of simultaneous database queries.

Most developers are aware of this in theory but do not address it in practice because IMemoryCache.GetOrCreateAsync does not protect against it. Multiple concurrent callers can execute the factory delegate simultaneously for the same key.

The fix: Use HybridCache.GetOrCreateAsync (.NET 9+), which includes stampede protection out of the box β€” only one caller executes the factory; all others wait and receive the same result. If you are not yet on .NET 9, use a keyed SemaphoreSlim per cache key to serialise access for high-contention keys.


Mistake 3: Setting No Expiration, or the Wrong Expiration Type

Teams that add caching as a performance fix often set absolute expirations that are either too long (introducing stale data) or too short (negating the cache benefit). A subtler and more common mistake is using only sliding expiration without an absolute expiration cap.

Sliding expiration resets the clock on every cache hit. For popular items that are continuously accessed, the entry never expires β€” even if the underlying data changes. This produces caches that grow unbounded and serve data that is arbitrarily stale.

The fix: Always pair sliding expiration with an absolute expiration upper bound. The sliding expiration handles low-traffic items (evicting them when nobody accesses them), and the absolute expiration ensures no entry lives beyond a defined maximum, regardless of how frequently it is accessed. The Microsoft caching documentation on ASP.NET Core caching overview covers both mechanisms and their interaction.


Mistake 4: Caching at the Wrong Layer

A common architectural mistake is adding caching directly inside a controller or a repository class rather than in a dedicated caching layer or service decorator. When caching logic is scattered across controllers, it becomes impossible to audit what is cached, change expiration policies uniformly, or invalidate entries reliably.

Equally problematic is caching the wrong data. Teams frequently cache database rows (entity objects) rather than the shaped, serialised response their API actually returns. Caching raw entities is risky because the same entity might render differently depending on user context, query parameters, or business rules applied upstream.

The fix: Encapsulate caching in a service decorator pattern β€” a wrapper that implements the same interface as the real service, delegates to it on cache misses, and manages the cache lifecycle. This makes caching a cross-cutting concern, auditable in one place. Cache the final response shape, not raw domain objects, unless the response is genuinely context-free.


Mistake 5: Treating Cache Keys as an Afterthought

Poorly constructed cache keys cause two categories of failure: key collisions (different logical requests mapping to the same key, returning incorrect data) and key explosion (unbounded key growth consuming all available memory).

A common collision example is caching paginated results with a key like "products" regardless of the page number or filter parameters. Every user gets the first page. The opposite problem appears when developers include too much context in the key β€” serialising an entire filter object including timestamps or session-specific data, generating millions of unique keys that individually never expire.

The fix: Define a deterministic, minimal key structure for each cache entry: resource type + stable identity parameters. For paginated queries: "products:page:{page}:size:{size}:category:{category}". For user-specific data that truly needs caching, include the user identifier. Document your key schema and enforce it in the caching service layer so it cannot diverge across the codebase.


Mistake 6: Not Planning for Cache Invalidation

"There are only two hard things in computer science: cache invalidation and naming things." It is a clichΓ© because it is true. Most teams plan the "cache on read" path carefully and ignore the "invalidate on write" path entirely β€” until they ship a bug where an update is invisible to users for the entire duration of the cache TTL.

Explicit invalidation is missed because writes are often scattered β€” a controller endpoint, a background job, an event handler, a webhook receiver. If all of these can mutate the same data but only some of them call cache.Remove(key), the cache will serve stale data after any write path that skips invalidation.

The fix: Use tag-based invalidation where the caching provider supports it. HybridCache supports RemoveByTagAsync, which lets you associate related entries under a logical tag (e.g., "products") and invalidate them atomically without knowing every specific key. Output caching in ASP.NET Core also supports tag-based eviction via IOutputCacheStore. For IDistributedCache, build a thin service that tracks key-to-tag mappings in a Redis set and invalidates by scanning it on write.


Mistake 7: Caching Sensitive or User-Specific Data in a Shared Cache

This mistake is a security issue as much as a correctness issue. When a shared IDistributedCache (Redis) holds user-specific or sensitive data without proper key namespacing and access control, a subtle key collision or a misconfiguration can cause one user's data to be returned to a different user.

Less obviously, teams sometimes cache the results of authenticated endpoints in the output cache without varying the cache by authentication identity. The first authenticated user's response gets cached and served to all subsequent users β€” a serious data leakage bug.

The fix: For user-specific data, always include a user identifier in the cache key and verify it on retrieval. For output caching authenticated endpoints, use SetVaryByHeader("Authorization") or vary by a claim extracted at the policy level. Never cache sensitive data without a defined expiration and a clear invalidation path. When in doubt, do not cache personalised responses β€” the correctness cost far outweighs the performance gain.


What Does Good ASP.NET Core Caching Look Like?

The common thread across all seven mistakes is missing intent. Caching decisions should be deliberate: what data, for how long, at which layer, with which invalidation strategy. Before adding any cache entry, answer three questions:

  1. How stale is acceptable? This determines your maximum TTL.

  2. Who can write this data? This determines your invalidation surface.

  3. Is it shared or user-specific? This determines whether a shared cache is safe.

ASP.NET Core gives you the right tools β€” IMemoryCache, IDistributedCache, HybridCache, and output caching β€” to address every tier of the caching stack. The mistakes above are not tool failures; they are planning failures that the right mental model prevents.

For a working implementation of HybridCache with tag-based invalidation in a production-style API, the full source code is available on GitHub: dotnet-hybridcache-aspnetcore.


β˜• Prefer a one-time tip? Buy us a coffee β€” every bit helps keep the content coming!


Frequently Asked Questions

Is IMemoryCache thread-safe in ASP.NET Core? Individual get and set operations on IMemoryCache are thread-safe. However, the factory delegate passed to GetOrCreateAsync is not protected against concurrent execution β€” multiple threads can execute it simultaneously for the same key on a cache miss. This is the cache stampede problem. Use HybridCache (.NET 9+) or a keyed SemaphoreSlim to serialise factory execution for high-contention keys.

When should I use IDistributedCache instead of IMemoryCache? Use IDistributedCache (backed by Redis or another distributed store) when your application runs as more than one instance and cache consistency across instances matters. If you are running a single-instance deployment and the data is truly process-local, IMemoryCache is faster and simpler. For most production APIs running in Kubernetes or Azure App Service with multiple replicas, IDistributedCache or HybridCache is the right default.

What is HybridCache and when should I use it?HybridCache is a .NET 9+ library (Microsoft.Extensions.Caching.Hybrid) that combines an in-process L1 cache (IMemoryCache) with an optional distributed L2 cache (IDistributedCache). It provides stampede protection, tag-based invalidation, and automatic serialisation. Use it when you want the performance of in-memory caching with the consistency and durability of a distributed store β€” without writing the two-layer coordination logic yourself.

How do I prevent cache stampedes without HybridCache? If you are on .NET 8 or earlier, use a ConcurrentDictionary<string, SemaphoreSlim> to create per-key locks. When a request misses the cache, it acquires the lock for that key, executes the factory, populates the cache, then releases the lock. Subsequent concurrent requests queue behind the lock and read from the cache once the first request completes. This pattern works but adds complexity β€” it is one of the primary motivations for adopting HybridCache.

Should I cache the results of EF Core queries? Yes, but selectively. Cache read-heavy, write-light data with a defined TTL and an explicit invalidation path on write. Do not cache data that changes frequently, user-specific query results without proper key namespacing, or the results of queries that depend on the current user's permission scope. The cache-aside pattern β€” check cache, populate on miss, invalidate on write β€” is the right default approach for EF Core results.

What is tag-based cache invalidation and how does it work in ASP.NET Core? Tag-based invalidation lets you associate one or more logical tags with a cache entry at write time, then invalidate all entries sharing a tag with a single call. Instead of tracking individual cache keys to remove when a product changes, you tag all product-related entries with "products" and call RemoveByTagAsync("products") on any write. HybridCache and ASP.NET Core's output caching both support this natively. For IDistributedCache, you must implement it yourself using Redis sets.

Is output caching safe for authenticated endpoints? Only if you configure vary-by rules correctly. By default, output caching does not vary by user identity β€” the first response for a route is cached and served to all subsequent callers. For authenticated endpoints that return user-specific data, you must vary by an authentication header, a user claim, or a request parameter that distinguishes users. For endpoints returning truly sensitive personal data, prefer not using output caching at all and rely on application-layer caching with per-user keys instead.

More from this blog

C

Coding Droplets

201 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.