IMemoryCache in ASP.NET Core: Where to Put It, and Why It Matters
Caching is one of those things that's easy to bolt on and hard to bolt on well. The .NET runtime gives you IMemoryCache out of the box — fast, simple, in-process. The harder question isn't whether to use it, it's where in your architecture to place it, and what contract it should respect.
This post documents the approach I took on a production enterprise MVC application — a multi-module ASP.NET Core system handling significant concurrent read traffic against a SQL Server backend. I'll walk through the architectural decision, the implementation pattern, the invalidation strategy, and the tradeoffs I consciously accepted.
The Problem
The application serves a paginated listing view that is hit frequently, by multiple users, often with the same or similar parameters. Behind that view lives a stored procedure that joins several large tables. On a quiet day, each page load means a round-trip to SQL Server costing 350–800 ms. Under moderate load (around 20 concurrent users), the database CPU was sitting at 45–60%, with response times in the 600–1200 ms range. Nothing was broken — but nothing was fast either.
The data in question is read-heavy and changes infrequently. That's the sweet spot for IMemoryCache.
Architectural Decision: Where Does the Cache Live?
This is the question that matters most. You have three natural candidates in a layered MVC application:
| Layer | Approach | Verdict |
|---|---|---|
| Controller | Inject IMemoryCache directly, cache before returning View |
❌ Violates SoC — business logic in the presentation layer |
| Service | Service wraps repository, caches fetched results | ✅ Right place — orchestration belongs here |
| Repository | Repository caches its own query results | ❌ Couples data access with cache lifecycle; invalidation becomes painful |
The controller is tempting because it's close to the HTTP request — you're already deciding what to render. But mixing cache logic with routing, ViewData setup, and HTTP response concerns is a fast path to code that's hard to test and hard to reason about.
The repository is also tempting because it's close to the data. But a repository's contract is "give me data from the store". If you cache inside it, you now have a repository that sometimes doesn't talk to the store, and invalidation becomes entangled with the persistence layer.
The service layer is the right home. A service's contract is orchestration: it coordinates data retrieval, applies business rules, and returns a result. Deciding to serve that result from cache rather than the database is an orchestration decision. It belongs here.
This also means the cache is invisible to the controller. The controller asks the service for data. It doesn't know, and shouldn't care, whether the service fetched it from the database or from memory.
Setup
Registration is a single line in Program.cs:
builder.Services.AddMemoryCache();
IMemoryCache is then injectable via the standard DI container across your services.
The Service Pattern
Here's a condensed version of the pattern I implemented. The service has three responsibilities: serve from cache on a hit, populate the cache on a miss, and invalidate the cache when the data changes.
public class DataService : IDataService
{
private readonly IDataRepository _repository;
private readonly IMemoryCache _cache;
private readonly ILogger<DataService> _logger;
private static readonly TimeSpan ListCacheDuration = TimeSpan.FromMinutes(5);
private static readonly TimeSpan MetadataCacheDuration = TimeSpan.FromMinutes(30);
private const string MetadataCacheKey = "module:metadata";
private const string ListCachePrefix = "module:list:";
// Shared cancellation token for bulk invalidation
private static CancellationTokenSource _cts = new();
private static readonly object _ctsLock = new();
public DataService(
IDataRepository repository,
IMemoryCache cache,
ILogger<DataService> logger)
{
_repository = repository;
_cache = cache;
_logger = logger;
}
public async Task<PagedResult<DataModel>> GetPagedAsync(
string username, int page, int pageSize, string orderBy, string filter)
{
var cacheKey = $"{ListCachePrefix}{username}_{page}_{pageSize}_{orderBy}_{filter}";
if (_cache.TryGetValue(cacheKey, out PagedResult<DataModel>? cached))
{
_logger.LogDebug("Cache HIT: {Key}", cacheKey);
return cached!;
}
_logger.LogDebug("Cache MISS: {Key}", cacheKey);
var result = await _repository.GetPagedAsync(username, page, pageSize, orderBy, filter);
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(ListCacheDuration)
.AddExpirationToken(new CancellationChangeToken(_cts.Token));
_cache.Set(cacheKey, result, options);
return result;
}
public void InvalidateCache()
{
lock (_ctsLock)
{
_cts.Cancel();
_cts.Dispose();
_cts = new CancellationTokenSource();
}
_cache.Remove(MetadataCacheKey);
_logger.LogInformation("Cache invalidated.");
}
}
A few things worth calling out explicitly.
Cache Key Design
The cache key must uniquely identify a result. For a paginated listing, that means encoding every parameter that influences the output:
// ✅ Encodes all relevant parameters
$"module:list:{username}_{page}_{pageSize}_{orderBy}_{filter}"
// ❌ Missing username — users would see each other's filtered results
$"module:list:{page}_{pageSize}"
Namespace your keys with a prefix (module:). It makes bulk invalidation easier to reason about, and makes log output readable.
Avoid GetHashCode() in keys — the default implementation is not guaranteed to be deterministic across process restarts in .NET.
Dual TTL Strategy
Not all cached data has the same freshness requirement. In this application, I applied two different TTLs:
- Paginated listing results: 5 minutes — these change when users create, update, or delete records
- Metadata (a "competence date" derived from a separate stored procedure): 30 minutes — this value changes at most once a day
The rule of thumb I settled on:
| Data type | Recommended TTL |
|---|---|
| Transactional listing | 3–10 minutes |
| Slow-changing metadata | 20–60 minutes |
| Static lookup tables | 6–24 hours |
| App-lifetime config | Application lifetime |
One heuristic worth keeping: if you're tempted to set a TTL above 1 hour for transactional data, stop and ask whether the stale data risk is acceptable. Usually it isn't.
Bulk Invalidation with CancellationToken
This is the piece that most caching guides skip over. You're storing paginated results with composite keys — page=1, filter=X, page=2, filter=X, page=1, filter=Y. When a user creates a new record, all of those entries are potentially stale. How do you invalidate them all?
IMemoryCache has no built-in API to enumerate keys or remove by prefix. You have two options:
Track keys manually — maintain a separate list of all cache keys per namespace, iterate and remove. This is O(n), fragile, and adds state you need to synchronize.
Use a shared
CancellationToken— all cache entries registered with the sameCancellationChangeTokenexpire atomically when the token is cancelled. This is O(1).
I chose option 2. The CancellationTokenSource is static (shared across all service instances in the process), guarded by a lock because CancellationTokenSource is not thread-safe:
public void InvalidateCache()
{
lock (_ctsLock)
{
_cts.Cancel(); // All entries linked to this token expire immediately
_cts.Dispose();
_cts = new CancellationTokenSource(); // Fresh token for new entries
}
}
The controller calls InvalidateCache() at the end of any mutation (Create, Update, Delete). The next read will miss the cache and repopulate it from the database.
Why not granular invalidation? I considered it. For each record mutation, you could theoretically figure out which pages it affects and remove only those entries. In practice this requires knowing the current sort order, active filters, and total record count — all of which you'd need to query the database to determine anyway. The marginal hit rate improvement doesn't justify the complexity. With a 5-minute TTL, the cost of an unnecessary cache miss is trivial.
Thread Safety
IMemoryCache itself is thread-safe — no locking needed around Get, Set, or Remove. The one place where you need a lock is around the static CancellationTokenSource, as shown above. If two threads call InvalidateCache() concurrently without a lock, you get a race between Cancel() and Dispose() on the same object.
Logging and Observability
Log cache hits and misses at Debug level. In development you get full visibility. In production you leave logging at Information or above and avoid the overhead.
_logger.LogDebug("Cache HIT: {Key}", cacheKey);
_logger.LogDebug("Cache MISS: {Key}", cacheKey);
_logger.LogInformation("Cache invalidated.");
To measure hit rate in production logs:
# PowerShell — count hits and misses from a structured log file
Select-String -Path "logs/app-*.log" -Pattern "Cache HIT" | Measure-Object
Select-String -Path "logs/app-*.log" -Pattern "Cache MISS" | Measure-Object
A hit rate below 50% usually means your TTL is too short, your data mutates too frequently, or your cache keys are not stable enough.
Real Numbers
After rolling out this pattern on the application, the difference was measurable:
| Metric | Before | Cache hit | Improvement |
|---|---|---|---|
| Page render time | 600–1200 ms | 50–150 ms | ↓ ~85% |
| DB CPU (20 users) | 45–60% | 12–18% | ↓ ~70% |
| DB roundtrips per session | 15–25 | 2–4 | ↓ ~85% |
| Timeout errors under load | 3.2% | 0% | ↓ 100% |
Under a simulated stress test (50 concurrent users, 70% reads, 10% mutations), P95 response time dropped from 1850 ms to 420 ms.
Expected hit rate in practice: 75–85%, depending on how frequently users filter and sort differently.
Pros and Cons
No pattern is free. Here's what you're signing up for:
Pros
- Dramatic reduction in database load and response latency with minimal code
- Fully integrated with ASP.NET Core DI — no external dependencies
- Single-process only: no network hop, no serialization, nanosecond lookup
- Easy rollback: swap
IDataServiceback toIDataRepositoryin the controller, remove service registration
Cons
- In-memory only: cache does not survive process restarts or scale across multiple nodes
- Load-balanced deployments need a distributed cache (Redis, SQL-backed) instead
- Memory usage grows with cached data — set
SizeLimitonAddMemoryCacheif this is a concern - Stale data window: a mutation from one user will not be reflected for other users until invalidation fires or TTL expires (the
InvalidateCache()call in mutation handlers closes most of this gap) - Static
CancellationTokenSourcerequires care — it's a process-wide lock
If you're running behind a load balancer with multiple application instances, IMemoryCache is the wrong tool. Each instance has its own cache, mutations on instance A don't invalidate instance B's cache. Use IDistributedCache with Redis in that scenario.
Summary
The short version:
- Register
IMemoryCacheinProgram.cs— one line - Put cache logic in the service layer, not the controller, not the repository
- Key your entries on all parameters that influence the result
- Use absolute expiration with a sane TTL, not sliding expiration for lists
- Use a shared
CancellationTokenfor bulk invalidation — it's O(1) and clean - Call
InvalidateCache()from every mutation handler - Lock around
CancellationTokenSource— it's not thread-safe - Log hits and misses at
Debug; measure hit rate in production
The architecture stays clean because the cache is an implementation detail of the service. Controllers don't know it exists. Repositories stay pure. The caching behavior can be tested in isolation, replaced, or removed without touching either end of the stack.
Code samples in this post are simplified for clarity. The pattern shown is language-idiomatic C# targeting .NET 8/10 with ASP.NET Core MVC and a Repository + Service layered architecture.