Caching in .NET: IMemoryCache, Distributed Cache with Redis, and Response Caching
Published:
This post covers the main caching options in .NET applications: in-memory cache, distributed cache, Redis, and HTTP response caching. Caching can reduce latency and database load, but it also introduces correctness questions. The hard part is not storing data. The hard part is knowing when cached data is valid.
Why caching matters
Caching is useful when:
- data is expensive to compute
- data is read frequently
- data changes less often than it is read
- downstream systems need protection from repeated calls
Bad caching creates stale data and confusing bugs. Good caching has clear expiration, key naming, invalidation rules, and metrics.
IMemoryCache
IMemoryCache stores data inside the current process. It is fast and simple.
Register it:
builder.Services.AddMemoryCache();
Use it in a service:
public sealed class ProductLookupService(
IMemoryCache cache,
IProductRepository repository)
{
public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
var cacheKey = $"products:{id}";
return await cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
entry.SlidingExpiration = TimeSpan.FromMinutes(1);
return await repository.GetByIdAsync(id, cancellationToken);
});
}
}
Use IMemoryCache when:
- the app runs as a single instance
- cached data can be different per process
- losing the cache on restart is acceptable
Limitations:
- each app instance has its own cache
- cache entries disappear when the process restarts
- memory pressure matters
Distributed cache
IDistributedCache stores cache entries outside the process. It is useful when multiple app instances must share cached values.
Register Redis:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "store-api:";
});
Use it:
public sealed class DistributedProductCache(IDistributedCache cache)
{
public async Task CacheProductAsync(ProductDto product, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(product);
await cache.SetStringAsync(
$"products:{product.Id}",
json,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
},
cancellationToken);
}
}
Distributed caching is a better fit for:
- load-balanced APIs
- containerized apps with multiple replicas
- data shared across workers and APIs
Cache key design
Cache keys should be predictable and versionable.
Good examples:
products:42
products:list:category:keyboard:page:1:size:25
tenant:acme:settings
Avoid vague keys like:
data
result
cache1
If the shape of the cached data changes, consider including a version:
products:v2:42
Response caching
Response caching works at the HTTP response level. It is useful for public or semi-static responses.
Register middleware:
builder.Services.AddResponseCaching();
var app = builder.Build();
app.UseResponseCaching();
Controller example:
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
[HttpGet("catalog")]
public IActionResult GetCatalog()
{
return Ok(catalogService.GetPublicCatalog());
}
Response caching is not a replacement for application caching. It is one tool for HTTP-level cache behavior.
Invalidation strategies
Expiration is easy. Invalidation is the real design problem.
Common strategies:
- short TTLs for data that can tolerate slight staleness
- explicit removal when data changes
- versioned keys when cache shape changes
- event-based invalidation after writes
Example:
cache.Remove($"products:{productId}");
For distributed cache:
await cache.RemoveAsync($"products:{productId}", cancellationToken);
Caching best practices
Use these rules:
- cache read models, not tracked EF Core entities
- use DTOs or serialized payloads
- keep TTLs explicit
- avoid caching secrets
- monitor hit rates and latency
- document invalidation behavior for important keys
Common mistakes to avoid
Watch for these issues:
- caching data with no expiration
- assuming
IMemoryCacheis shared across servers - using one cache key for different query shapes
- caching authorization-sensitive responses incorrectly
- adding Redis before fixing inefficient queries
Caching should be a deliberate performance tool. Add it where repeated work is expensive, and make expiration and invalidation part of the design from the beginning.
Next Article: API Documentation in .NET: OpenAPI, Swagger, Examples, and Versioning