In-memory cache in ASP.NET Core is a good fit when one application instance repeatedly reads data that is expensive to load but safe to recreate. The practical pattern is cache-aside: read from the cache first, load from the real source on a miss, store the result with a bounded lifetime, and remove the entry after a successful write.
The cache must remain an optimization, never the source of truth. This guide builds a small production-oriented example, then covers expiration, invalidation, concurrent misses, memory limits, and the point where a local cache should be replaced or complemented by HybridCache or a distributed cache.
Table of Contents
When IMemoryCache is the right choice
IMemoryCache stores objects inside the current application process. It is fast, requires no network call, integrates with ASP.NET Core dependency injection, and can store typed objects without serialization. Those strengths make it useful for reference data, configuration-derived results, expensive calculations, or database queries whose results can be briefly stale.
Its boundary is equally important: every server has a separate cache, and every restart or deployment clears it. A local entry is also shared by concurrent requests inside that process, so mutable cached objects can create data races. Cache immutable records, arrays, or read-only collections, and treat a miss as normal application behavior.
- Use
IMemoryCachewhen temporary per-instance data is acceptable. - Use a distributed cache when several application instances must observe shared cached state.
- Do not cache authorization decisions, one-time secrets, or correctness-critical state unless the invalidation and security model is explicit.
Use in-memory cache in ASP.NET Core
Register the cache and the application service at startup. IMemoryCache is a singleton service, while ProductQueryService can remain scoped because it coordinates a request-oriented repository. If service ownership is unfamiliar, the ASP.NET Core dependency injection lifetime guide explains the boundary in more detail.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<ProductQueryService>();
The cache-aside service below uses a stable, namespaced key. It checks the cache, loads from the repository on a miss, and stores only a real product. A missing product is not cached here because a newly created record should become visible immediately; if repeated misses are expensive, add a deliberately short negative-cache policy instead of accidentally caching null.
public sealed record ProductSummary(
int Id,
string Name,
decimal Price);
public sealed record UpdateProduct(
int Id,
string Name,
decimal Price);
public interface IProductRepository
{
Task<ProductSummary?> FindAsync(
int id,
CancellationToken cancellationToken);
Task UpdateAsync(
UpdateProduct command,
CancellationToken cancellationToken);
}
public sealed class ProductQueryService(
IMemoryCache cache,
IProductRepository repository)
{
private static string ProductKey(int id) => $"products:v1:{id}";
public async Task<ProductSummary?> GetAsync(
int id,
CancellationToken cancellationToken)
{
var key = ProductKey(id);
if (cache.TryGetValue<ProductSummary>(key, out var cached))
{
return cached;
}
var product = await repository.FindAsync(id, cancellationToken);
if (product is null)
{
return null;
}
var options = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromMinutes(2))
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10));
cache.Set(key, product, options);
return product;
}
public void Invalidate(int id) => cache.Remove(ProductKey(id));
}
Returning an immutable record matters because an in-memory cache returns the same object reference to callers in the process. If one request mutates a cached list or entity, another request can observe that change without any database write. Project database entities into immutable cache models and never expose a mutable cached collection for callers to edit.
Choose expiration deliberately
Expiration defines the maximum staleness the application accepts; it should come from the data’s behavior, not a copied number. Sliding expiration keeps a frequently accessed entry alive, while absolute expiration places a hard upper bound on its lifetime. Combining them prevents a hot entry from remaining cached forever.
- Absolute expiration: use it when the data must be refreshed within a known maximum interval.
- Sliding expiration: use it to remove inactive entries while keeping a useful hot entry available.
- Both: use sliding expiration for inactivity and absolute expiration as the safety ceiling.
Expiration is not an exact background scheduler. The memory cache does not continuously scan every entry with a dedicated timer; cache activity can trigger expiration scans. Code must therefore rely on cache reads returning a hit or miss, not on an assumption that an expired object is removed at an exact clock tick.
Invalidate cached data after writes
A time-to-live limits stale data but does not make it fresh after a write. Remove the affected key only after the source-of-truth update succeeds. Removing it first can allow another request to refill the cache with the old value while the database transaction is still in progress.
public async Task UpdateAsync(
UpdateProduct command,
CancellationToken cancellationToken)
{
await repository.UpdateAsync(command, cancellationToken);
cache.Remove(ProductKey(command.Id));
}
This is still an eventually consistent cache-aside sequence, not a transaction spanning the database and memory. A concurrent reader can be racing with the write. If the application requires stronger guarantees, use versioned data, a coordinated invalidation mechanism, or a cache architecture designed for that consistency requirement instead of treating a short expiration as proof of correctness.
Handle concurrent cache misses
A common mistake is assuming that IMemoryCache.GetOrCreateAsync provides single-flight behavior. It does not guarantee that only one factory runs for a missing key. Several requests can observe the miss and execute the data-loading callback concurrently, which can overload a database or remote API when a popular entry expires.
For a cheap query and modest traffic, duplicate work may be acceptable and simpler than additional coordination. For expensive hot keys, install Microsoft.Extensions.Caching.Hybrid and use HybridCache, which coordinates concurrent callers for the same key within one cache instance and can also use a configured distributed cache as secondary storage.
dotnet add package Microsoft.Extensions.Caching.Hybrid
builder.Services.AddHybridCache();
public sealed class ProductHybridQuery(
HybridCache cache,
IProductRepository repository)
{
public Task<ProductSummary?> GetAsync(
int id,
CancellationToken cancellationToken) =>
cache.GetOrCreateAsync(
$"products:v1:{id}",
async token => await repository.FindAsync(id, token),
cancellationToken: cancellationToken).AsTask();
}
The coordination is local to a HybridCache instance; it does not turn several servers into one global lock. In a scaled deployment, choose the secondary store, invalidation strategy, and acceptable cross-node staleness explicitly.
Control memory growth safely
The ASP.NET Core runtime does not impose a memory-size limit on the default cache for the application. Expiration, bounded key cardinality, and deliberate payload size are the first controls. Never build keys directly from unrestricted user input, because attackers or accidental high-cardinality values can keep creating new entries.
Do not set SizeLimit on the shared IMemoryCache registered by the framework. Once a size limit exists, every entry added to that cache must specify a size, including entries created by components you may not control. Use a dedicated cache when the application needs an enforced limit.
public sealed class ProductMemoryCache : IDisposable
{
public MemoryCache Store { get; } = new(
new MemoryCacheOptions
{
SizeLimit = 1_000
});
public void Dispose() => Store.Dispose();
}
builder.Services.AddSingleton<ProductMemoryCache>();
var entryOptions = new MemoryCacheEntryOptions()
.SetSize(1)
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10));
productCache.Store.Set(key, product, entryOptions);
The size value is unitless. In this example, each product counts as one entry, so the limit is an entry-count budget, not 1,000 bytes. All producers that use this dedicated cache must follow the same unit convention. Avoid CacheItemPriority.NeverRemove for ordinary data because pinned entries weaken eviction as a safety mechanism.
Verify cache behavior
Do not prove caching by comparing one Postman response time with another; network, database warm-up, and JIT compilation make that evidence unreliable. Use a counting fake or repository telemetry to verify hits, misses, and invalidation deterministically.
[Fact]
public async Task Second_read_hits_cache_and_invalidation_reloads()
{
using var cache = new MemoryCache(new MemoryCacheOptions());
var repository = new CountingProductRepository(
new ProductSummary(42, "Keyboard", 99m));
var service = new ProductQueryService(cache, repository);
await service.GetAsync(42, CancellationToken.None);
await service.GetAsync(42, CancellationToken.None);
Assert.Equal(1, repository.ReadCount);
service.Invalidate(42);
await service.GetAsync(42, CancellationToken.None);
Assert.Equal(2, repository.ReadCount);
}
private sealed class CountingProductRepository(ProductSummary product)
: IProductRepository
{
public int ReadCount { get; private set; }
public Task<ProductSummary?> FindAsync(
int id,
CancellationToken cancellationToken)
{
ReadCount++;
return Task.FromResult<ProductSummary?>(product);
}
public Task UpdateAsync(
UpdateProduct command,
CancellationToken cancellationToken) => Task.CompletedTask;
}
The test verifies the contract that matters: two sequential reads produce one repository call, and removing the key forces the next read back to the source. Add a separate concurrent test if the chosen implementation promises request coalescing; the basic IMemoryCache service above intentionally makes no such promise.
In production, record cache hits and misses as bounded metrics rather than logging every key. Monitor process memory, source-call rate after expirations, eviction reasons when useful, and latency during deployments. A cache that improves the average response while creating periodic database spikes is not healthy.
Historical .NET 7 implementation
The original article used an employee database and an ASP.NET Core Web API targeting net7.0. The original code is retained below as historical material and has not been re-tested against the current .NET SDK. It also contains an original naming mismatch: the CLI command creates CS.KeyVault.ApiApp although the surrounding example names the project CS.MemoryCache.WebApi.
Original project and data-access setup
dotnet new webapi --output CS.KeyVault.ApiApp --framework "net7.0" --use-program-main
The original project installed Microsoft.EntityFrameworkCore and Microsoft.EntityFrameworkCore.SqlServer, then defined the following entity and connection string.
namespace CS.MemoryCache.WebApi.Entities
{
public class Employee
{
public int EmployeeId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Job { get; set; }
public int Salary { get; set; }
public DateTime HireDate { get; set; }
public int? Manager { get; set; }
}
}
"ConnectionStrings": {
"EmployeeConnection": "Server=(localdb)\\MSSQLLocalDB;Database=CS-Employee-Database;Trusted_Connection=True;MultipleActiveResultSets=true"
}
namespace CS.MemoryCache.WebApi.Services
{
public class EmployeeDbContext : DbContext
{
public EmployeeDbContext(DbContextOptions<EmployeeDbContext> options) :
base(options)
{
}
public DbSet<Employee> Employees { get; set; }
}
}
var configuration = builder.Configuration;
builder.Services.AddDbContext<EmployeeDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("EmployeeConnection")));
namespace CS.MemoryCache.WebApi.Services.Interfaces
{
public interface IEmployeeService
{
Task<IEnumerable<Employee>> GetEmployees();
}
}
public class EmployeeService : IEmployeeService
{
private readonly EmployeeDbContext employeeDbContext;
public EmployeeService(EmployeeDbContext employeeDbContext)
{
this.employeeDbContext = employeeDbContext;
}
public async Task<IEnumerable<Employee>> GetEmployees()
{
return await employeeDbContext.Employees.ToListAsync();
}
}
builder.Services.AddScoped<IEmployeeService, EmployeeService>();
Original cache code
The original implementation registered IMemoryCache and injected it into EmployeeController.
builder.Services.AddMemoryCache();
namespace CS.MemoryCache.WebApi.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class EmployeeController : ControllerBase
{
private readonly IEmployeeService employeeService;
private readonly IMemoryCache memoryCache;
public EmployeeController(
IMemoryCache memoryCache,
IEmployeeService employeeService)
{
this.memoryCache = memoryCache;
this.employeeService = employeeService;
}
[HttpGet("employees")]
public async Task<IEnumerable<Employee>> GelAllEmployees()
{
IEnumerable<Employee> cacheEmployees;
if (!memoryCache.TryGetValue(
"cacheEmployees",
out cacheEmployees))
{
cacheEmployees = await employeeService.GetEmployees();
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(100))
.SetAbsoluteExpiration(TimeSpan.FromSeconds(200))
.SetPriority(CacheItemPriority.NeverRemove)
.SetSize(1024);
memoryCache.Set(
"cacheEmployees",
cacheEmployees,
cacheEntryOptions);
}
memoryCache.TryGetValue(
"cacheEmployees",
out cacheEmployees);
return cacheEmployees;
}
}
}
This block is preserved, not recommended as the modern implementation. SetSize(1024) does not establish a cache limit by itself; it matters only when the cache instance has a configured SizeLimit. The original code uses the shared dependency-injection cache without configuring that limit, and NeverRemove is a poor default for ordinary database results. The modern implementation earlier in this article replaces those choices with bounded expiration and explicit invalidation.
The original in-memory caching repository remains available as the historical demo. Its project versions and behavior should be evaluated in their original environment; the repository is not presented here as a current production baseline.
Production checklist
- Keep the database or remote service as the source of truth.
- Use stable namespaced keys with bounded cardinality.
- Cache immutable projections rather than tracked entities or mutable collections.
- Set an absolute expiration; add sliding expiration only when it matches the data policy.
- Invalidate after a successful source write.
- Decide whether concurrent misses are acceptable; use
HybridCachewhen hot-key stampede protection is required. - Use a dedicated cache if an enforced
SizeLimitis required. - Use distributed storage when several servers need shared cached state.
- Test hit, miss, expiration, invalidation, and source-failure paths.
IMemoryCache is valuable because it is small and local, but those same properties define its limits. Keep the cache optional, bound its lifetime and growth, invalidate it deliberately, and move to a coordinated cache only when the application’s traffic or deployment model requires one.
References
- Microsoft Learn: Cache in-memory in ASP.NET Core
- Microsoft Learn: HybridCache library in ASP.NET Core
- Microsoft Learn: Distributed caching in ASP.NET Core
- Microsoft Learn: CacheExtensions.GetOrCreateAsync API
Found this useful? Support more practical developer content.