lowest possible latency for server-side caching with the highest degree of control over cache variation. The ability to swap from In-Memory to Redis via configuration alone, without touching controller code, significantly reduces technical debt and deployment risk.
Core Solution
Architecture Overview
Output Caching operates as middleware. When enabled, it generates a cache key based on the request characteristics and the defined policy. If a match is found in the storage provider, the response is returned. If not, the request proceeds through the pipeline, and the response is stored before being sent to the client.
Key components:
IOutputCacheStore: Interface for storage backends.
BaseCachePolicy: Defines how cache keys are generated and responses are stored.
CacheOutputOptions: Configures expiration, vary-by rules, and tags.
Step-by-Step Implementation
1. Service Registration and Middleware
Register the caching services and add the middleware to the pipeline. The middleware must be placed after routing but before endpoint execution.
var builder = WebApplication.CreateBuilder(args);
// Register Output Caching services
builder.Services.AddOutputCache();
// Optional: Add Redis for distributed caching
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
// Register Output Caching with Redis store
builder.Services.AddOutputCache(options =>
{
options.AddRedisProvider();
});
var app = builder.Build();
// Add Output Caching middleware
// Must be after UseRouting and before MapControllers/MapEndpoints
app.UseRouting();
app.UseOutputCache();
app.MapGet("/api/products", () => Results.Ok(GetProducts()))
.CacheOutput();
app.Run();
2. Configuring Vary-By Policies
Cache keys must account for request variations. Failing to vary correctly results in data leakage or stale responses.
app.MapGet("/api/products", () => Results.Ok(GetProducts()))
.CacheOutput(policy => policy
// Vary by query string parameter 'category'
.VaryByQuery("category")
// Vary by header 'Accept-Language'
.VaryByHeader("Accept-Language")
// Vary by route value 'id'
.VaryByRouteValues("id")
// Vary by user identity (critical for authenticated endpoints)
.VaryByUser()
// Set expiration
.Expire(TimeSpan.FromMinutes(10))
);
3. Advanced Policy Configuration
For complex scenarios, implement BaseCachePolicy to define custom key generation logic.
public class CustomProductPolicy : BaseCachePolicy
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CustomProductPolicy(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public override async ValueTask<bool> ServeFromCacheAsync(
CacheContext cacheContext, CancellationToken cancellationToken)
{
// Custom logic to determine if cache should be served
// e.g., check custom headers or tenant context
var tenant = cacheContext.HttpContext.Request.Headers["X-Tenant-Id"];
if (string.IsNullOrEmpty(tenant)) return false;
// Generate custom cache key
cacheContext.CacheKey = $"tenant_{tenant}_{cacheContext.HttpContext.Request.Path}";
return await base.ServeFromCacheAsync(cacheContext, cancellationToken);
}
public override async ValueTask ServeResponseAsync(
CacheContext cacheContext, CancellationToken cancellationToken)
{
// Store response with custom key
var tenant = cacheContext.HttpContext.Request.Headers["X-Tenant-Id"];
cacheContext.CacheKey = $"tenant_{tenant}_{cacheContext.HttpContext.Request.Path}";
await base.ServeResponseAsync(cacheContext, cancellationToken);
}
}
4. Cache Invalidation with Tags
Tags allow invalidation of related cache entries without knowing specific keys.
app.MapPost("/api/products", async (Product product, IOutputCacheStore cacheStore) =>
{
await SaveProduct(product);
// Invalidate all entries tagged with "products"
await cacheStore.EvictByTagAsync("products", CancellationToken.None);
return Results.Created();
}).CacheOutput(policy => policy.Tag("products"));
Pitfall Guide
1. Caching Authenticated Data Without VaryByUser
Mistake: Enabling output caching on endpoints returning user-specific data without varying by user.
Consequence: User A receives User B's data. This is a critical security vulnerability.
Fix: Always apply .VaryByUser() on authenticated endpoints. Alternatively, use policy to exclude authenticated requests from caching.
2. Ignoring Cache Stampede
Mistake: Allowing thousands of concurrent requests to hit an expired cache key simultaneously.
Consequence: Thundering herd problem; database or upstream service is overwhelmed as all requests execute the endpoint logic concurrently.
Fix: Implement locking mechanisms or use storage providers that support lock-on-miss. In .NET 8, consider using SemaphoreSlim within custom policies or leveraging Redis distributed locks for high-risk endpoints.
3. Caching Mutating Requests (POST/PUT/DELETE)
Mistake: Applying CacheOutput to endpoints that modify state.
Consequence: Mutations are cached, or subsequent reads return stale data. Output caching is designed for idempotent reads.
Fix: Never cache POST, PUT, PATCH, or DELETE requests. Use tags to invalidate cache entries when mutations occur.
4. Memory Pressure with Default In-Memory Cache
Mistake: Relying on the default in-memory provider in production without size limits.
Consequence: Unbounded memory growth leading to OutOfMemoryExceptions.
Fix: Configure SizeLimit in AddOutputCache or switch to a distributed provider like Redis with explicit eviction policies.
builder.Services.AddOutputCache(options =>
{
options.MaximumBodySize = 1024 * 1024 * 100; // 100MB limit
});
5. Misunderstanding VaryByQuery Behavior
Mistake: Assuming VaryByQuery without arguments varies by all query parameters.
Consequence: Incorrect cache hits if query parameters change but are not accounted for.
Fix: VaryByQuery() varies by all query parameters. VaryByQuery("param") varies only by that specific parameter. Use VaryByQuery("*") explicitly for all parameters to avoid ambiguity.
6. Stale Data in Distributed Environments
Mistake: Using In-Memory caching in a scaled-out environment (multiple instances).
Consequence: Cache consistency issues; different instances hold different data.
Fix: Use a distributed store (Redis, SQL Server, NCache) in multi-instance deployments. Ensure the storage provider is shared across all nodes.
7. Over-Caching Dynamic Content
Mistake: Caching endpoints with high volatility or real-time data.
Consequence: Users see outdated information; cache hit ratio is low due to rapid expiration.
Fix: Analyze data freshness requirements. Use short expiration times or disable caching for real-time endpoints. Monitor cache hit ratios to identify misconfigured policies.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High Read-Throughput API | Output Caching + Redis | Reduces latency to sub-millisecond; offloads database; scales horizontally. | Low (Redis cost vs. DB compute savings) |
| Personalized User Dashboard | Output Caching + VaryByUser | Caches per-user responses; avoids recomputing user-specific aggregations. | Medium (Increased cache storage per user) |
| Low-Traffic Internal Tool | Output Caching + In-Memory | Zero infrastructure cost; simple setup; sufficient for low concurrency. | None (Uses existing server RAM) |
| Real-Time Stock Ticker | Disable Caching / Short TTL | Data changes too frequently; caching adds complexity with minimal gain. | None |
| Multi-Tenant SaaS | Output Caching + Custom Policy + Redis | Isolates cache per tenant; ensures data security; scales across tenants. | Medium (Redis storage scaling) |
Configuration Template
// Program.cs
using Microsoft.Extensions.Caching.StackExchangeRedis;
var builder = WebApplication.CreateBuilder(args);
// 1. Configure Redis (if using distributed cache)
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration["Redis:ConnectionString"];
options.InstanceName = "MyApp_";
});
// 2. Register Output Caching with Redis Provider
builder.Services.AddOutputCache(options =>
{
options.AddRedisProvider();
// Global size limit to prevent memory issues
options.MaximumBodySize = 1024 * 1024 * 50; // 50MB
// Base policy defaults
options.AddBasePolicy(builder => builder
.Expire(TimeSpan.FromMinutes(5))
.Tag("global"));
});
var app = builder.Build();
app.UseRouting();
// 3. Add Output Caching Middleware
app.UseOutputCache();
// 4. Map Endpoints with Caching
app.MapGet("/api/data", () => Results.Ok("Dynamic Data"))
.CacheOutput(policy => policy
.VaryByQuery("filter")
.Tag("data")
.Expire(TimeSpan.FromMinutes(10)));
app.MapPost("/api/data", async (IOutputCacheStore cacheStore) =>
{
// Invalidate cache on mutation
await cacheStore.EvictByTagAsync("data", CancellationToken.None);
return Results.Ok();
});
app.Run();
Quick Start Guide
- Install Package: Ensure
Microsoft.AspNetCore.OutputCaching is included (part of ASP.NET Core shared framework). For Redis, add Microsoft.Extensions.Caching.StackExchangeRedis.
- Add Services: In
Program.cs, call builder.Services.AddOutputCache(). Configure storage if needed.
- Add Middleware: Call
app.UseOutputCache() after UseRouting().
- Tag Endpoints: Append
.CacheOutput() to your endpoint definitions. Add VaryBy rules as required.
- Run and Verify: Test with identical requests. Check response headers or use a debugger to confirm the endpoint logic is bypassed on subsequent calls.
ASP.NET Core Output Caching provides a robust, framework-native solution for performance optimization. By adhering to the policies and patterns outlined, developers can achieve significant latency reductions and resource savings while maintaining data integrity and security.