Output Caching in ASP.NET Core — Caching HTTP Responses
ASP.NET Core output caching: caching full HTTP responses, vary-by rules, cache policies, tag-based invalidation from handlers, and when to use output cache vs data cache.
Output Cache vs Data Cache
Data cache (IMemoryCache, IDistributedCache, HybridCache):
Caches objects in memory
You decide when to use the cached value
Fine-grained control over what is cached
Use for: business objects, query results, expensive computations
Output cache:
Caches the full HTTP response (status, headers, body)
Applied at the middleware level — handler does not run on cache hit
Coarser but simpler — no serialization code needed
Use for: public read-only endpoints that return the same response
for a given URL/query combinationSetup
// Program.cs
builder.Services.AddOutputCache(options =>
{
// Default policy for all cached endpoints
options.AddBasePolicy(policy =>
policy.Expire(TimeSpan.FromMinutes(5)));
// Named policies
options.AddPolicy("FormulaeryCachePolicy", policy =>
policy.Expire(TimeSpan.FromHours(4))
.SetVaryByQuery("hospitalId")
.Tag("formulary"));
options.AddPolicy("PatientListPolicy", policy =>
policy.Expire(TimeSpan.FromMinutes(2))
.SetVaryByQuery("page", "pageSize", "department")
.SetVaryByHeader("Authorization") // different cache per user
.Tag("patients"));
});
app.UseOutputCache(); // must be after UseRouting, before endpointsApplying to Endpoints
// Apply named policy
app.MapGet("/formulary", GetFormulary)
.CacheOutput("FormularyCachePolicy");
// Inline policy
app.MapGet("/drugs", GetDrugs)
.CacheOutput(policy =>
policy.Expire(TimeSpan.FromHours(2))
.Tag("drugs"));
// Disable for specific endpoints (override base policy)
app.MapPost("/prescriptions", AddPrescription)
.DisableOutputCache(); // mutations should never be cachedVary-By Rules
Output cache serves the same cached response to all requests unless you vary by something:
options.AddPolicy("DepartmentList", policy =>
policy
.Expire(TimeSpan.FromMinutes(10))
// Different cache entry per query string value
.SetVaryByQuery("department", "sortBy")
// Different cache entry per header value
// (e.g., different languages via Accept-Language)
.SetVaryByHeader("Accept-Language")
// Different cache entry per route value
.SetVaryByRouteValue("hospitalId")
// Different cache entry per user (claim)
// — only use when the response truly differs per user
.SetVaryByValue(ctx =>
{
var userId = ctx.Request.HttpContext.User
.FindFirstValue(JwtRegisteredClaimNames.Sub);
return new ValueTask<string>(userId ?? "anonymous");
}));Tag-Based Invalidation from Code
When data changes, evict related cached responses:
// In your mutation handler — inject IOutputCacheStore
public sealed class UpdateFormularyHandler
{
private readonly DrugRepository _repo;
private readonly IOutputCacheStore _outputCache;
public async Task<Result> Handle(
UpdateFormularyCommand cmd, CancellationToken ct)
{
// ... update in DB
// Invalidate all cached responses tagged "formulary"
await _outputCache.EvictByTagAsync("formulary", ct);
return Result.Success();
}
}Output Cache vs Cache-Control Headers
Cache-Control (browser caching):
Caches the response in the client's browser or CDN
The server has no control over eviction
Client requests a new response when Cache-Control expires
CDN-level: applies when using Azure Front Door, Cloudflare, etc.
Output Cache (server-side):
Caches the response in the server's memory
You control eviction with tags
Client always hits your server but gets cached response
Does not interact with browser cacheLocking and Stampede Protection
Output cache has built-in request queuing:
options.AddBasePolicy(policy =>
policy.Expire(TimeSpan.FromMinutes(5)));
// Default behavior: when the cache misses, only ONE request runs the handler
// Other concurrent requests wait for the first response, then serve the cached result
// This is built-in stampede protection — no SemaphoreSlim neededWhat NOT to Cache with Output Cache
Do not cache:
✗ POST/PUT/DELETE responses — mutations should never be cached
✗ Responses with user-specific data unless Vary-By-User is set
✗ Real-time data (patient vitals, current drug orders in progress)
✗ Paginated results where the data changes frequently
✗ Authenticated endpoints without proper Vary-By-Header("Authorization")
Cache safely:
✓ Public read-only endpoints (formulary, ICD codes, hospital info)
✓ Reference data that changes on admin action (trigger eviction)
✓ Listing endpoints with stable filter combinations
✓ Static report generation (once generated, same for TTL)Production issue I've seen: A team cached a
/patientslist response that included the current user's name in a sidebar. With output caching and noSetVaryByHeader("Authorization"), all users saw the first user's name in the sidebar. The fix: either exclude user-specific data from cached responses, or vary by the Authorization header.
Distributed Output Cache
// Default: in-memory output cache (per instance)
// For multi-instance: use Redis-backed output cache
builder.Services.AddStackExchangeRedisOutputCache(options =>
options.Configuration = builder.Configuration.GetConnectionString("Redis"));
// Now output cache entries are shared across all instances
// Eviction via EvictByTagAsync removes from all instances via Redis pub/subOutput Cache + Response Compression
// Add both — output cache stores the compressed response
builder.Services.AddResponseCompression(options =>
options.EnableForHttps = true);
// Middleware order:
app.UseResponseCompression(); // before output cache
app.UseOutputCache();Key Takeaway
Output caching caches complete HTTP responses at the middleware level — the handler does not run on a cache hit. It is simpler than data caching for endpoints that return the same response to many users. Use vary-by rules to ensure different users/queries get separate cache entries. Use tag-based invalidation to evict cached responses when the underlying data changes. For multi-instance, use Redis-backed output cache. Never cache mutation endpoints.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.