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:

  1. 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.

  2. Use a shared CancellationToken — all cache entries registered with the same CancellationChangeToken expire 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 IDataService back to IDataRepository in 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 SizeLimit on AddMemoryCache if 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 CancellationTokenSource requires 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 IMemoryCache in Program.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 CancellationToken for 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.