The Correctness Problem: Output Cache Is Easy to Get Wrong
ASP.NET Core's Output Cache middleware is fast, simple to wire up, and capable of eliminating a significant fraction of database and compute load for read-heavy endpoints. It is also the kind of feature where a single missing configuration line serves User A's private account summary to User B, or serves a page-1 product list in response to every ?page= query variant because the cache key does not include the query string.
The failure mode is not a crash. The application keeps running, response times stay fast, and cache hit rates look healthy. The problem is silent data correctness: users see the wrong data, and the only signal is a support ticket or a confused user. Getting Output Cache right requires understanding exactly how the cache key is formed, which dimensions of the request need to vary it, what TTL is safe for each endpoint's data, and how to verify the cache is behaving as intended. This article covers all four.
Setup and the Baseline Policy Pattern
Output Cache is built into ASP.NET Core from .NET 7 onwards — no additional NuGet package is required. Registration is two lines: AddOutputCache() in the service collection and UseOutputCache() in the middleware pipeline. The placement of UseOutputCache() in the pipeline matters: it must come after routing and authentication middleware so that route values and authentication context are available when the cache key is computed, but before the endpoint handler so that the cached response can be served without hitting the handler at all.
// ── Service registration ──────────────────────────────────────────────────
builder.Services.AddOutputCache(options =>
{
// ── Base policy: applies to ALL cached endpoints unless overridden ────
// Defines the floor behaviour — individual endpoint policies extend this.
options.AddBasePolicy(policy =>
{
policy.Expire(TimeSpan.FromSeconds(30));
// No VaryBy* here — base policy intentionally conservative.
// Vary-by rules belong on individual endpoint policies where
// the specific query parameters, headers, and route values are known.
});
// ── Named policy: public catalogue endpoints ──────────────────────────
// Product lists, category pages — public, paginated, filterable.
options.AddPolicy("PublicCatalogue", policy =>
{
policy
.Expire(TimeSpan.FromMinutes(5))
.VaryByQuery("page", "pageSize", "category", "sort")
.VaryByHeader("Accept-Language") // vary by locale if responses differ
.Tag("catalogue"); // invalidation tag — burst on data change
});
// ── Named policy: reference data ─────────────────────────────────────
// Countries, currencies, static lookup tables — changes only on release.
options.AddPolicy("ReferenceData", policy =>
{
policy
.Expire(TimeSpan.FromMinutes(60))
.Tag("reference");
// No VaryByQuery — these endpoints take no parameters.
// No VaryByHeader — response is identical regardless of locale.
});
// ── Named policy: search results ─────────────────────────────────────
// Short TTL — search results change as inventory updates.
options.AddPolicy("Search", policy =>
{
policy
.Expire(TimeSpan.FromSeconds(15))
.VaryByQuery("q", "page", "pageSize", "filters");
});
});
// ── Middleware pipeline — order is critical ───────────────────────────────
var app = builder.Build();
app.UseRouting(); // must be before UseOutputCache — route values needed for key
app.UseAuthentication(); // must be before UseOutputCache — auth context needed for vary-by
app.UseAuthorization();
app.UseOutputCache(); // ← here: after routing/auth, before endpoint handlers
app.MapControllers();
// ── Applying named policies to endpoints ──────────────────────────────────
// Minimal API — attribute on the handler:
app.MapGet("/api/products", GetProducts)
.CacheOutput("PublicCatalogue");
// Controller action — attribute on the action method:
// [OutputCache(PolicyName = "PublicCatalogue")]
// public IActionResult GetProducts([FromQuery] int page = 1) { ... }
// Inline policy override — for one-off endpoints that don't warrant a named policy:
app.MapGet("/api/status", GetStatus)
.CacheOutput(policy => policy.Expire(TimeSpan.FromSeconds(5)));
Named policies are the correct pattern for any non-trivial Output Cache configuration. Inline lambdas on individual endpoints scatter vary-by decisions across the codebase, making it impossible to audit which endpoints vary by which dimensions without reading every handler. Named policies centralise the decisions, make them reviewable in one place, and make the tag structure — which drives invalidation — visible as a coherent inventory rather than a hidden per-endpoint detail. Define the policy, name it descriptively, apply the name to endpoints.
Vary-By Rules: What Goes Into the Cache Key
The cache key determines whether a request gets a cache hit or a cache miss. Every dimension of the request that changes the response must be represented in the cache key — and nothing more. Under-specification produces wrong responses: two requests that differ in a meaningful way share a cache entry and one gets the other's data. Over-specification fragments the cache: too many vary-by dimensions means almost no two requests share an entry, hit rates collapse, and you have added middleware overhead without the caching benefit.
// ════════════════════════════════════════════════════════════════════════════
// VARY BY QUERY STRING
// ════════════════════════════════════════════════════════════════════════════
// WRONG: no VaryByQuery — all query variants share one cache entry
options.AddPolicy("ProductList_Wrong", policy =>
{
policy.Expire(TimeSpan.FromMinutes(5));
// GET /api/products?page=1 → cached
// GET /api/products?page=2 → cache HIT — returns page 1 data (wrong)
// GET /api/products?page=3 → cache HIT — returns page 1 data (wrong)
});
// RIGHT: vary by the query parameters that actually affect the response
options.AddPolicy("ProductList", policy =>
{
policy
.Expire(TimeSpan.FromMinutes(5))
.VaryByQuery("page", "pageSize", "category", "sort");
// GET /api/products?page=1&category=shoes → cache entry A
// GET /api/products?page=2&category=shoes → cache entry B (different key)
// GET /api/products?page=1&category=bags → cache entry C (different key)
// Query parameters NOT listed here are ignored in key formation.
// ?utm_source=email does not fragment the cache — correct.
});
// ════════════════════════════════════════════════════════════════════════════
// VARY BY ROUTE VALUE
// ════════════════════════════════════════════════════════════════════════════
// Route values are included in the base key automatically (the path is part
// of the key). But for parameterised routes, VaryByRouteValue makes the
// intent explicit and ensures the value is normalised correctly.
options.AddPolicy("ProductDetail", policy =>
{
policy
.Expire(TimeSpan.FromMinutes(10))
.VaryByRouteValue("productId");
// GET /api/products/123 → cache entry keyed on productId=123
// GET /api/products/456 → separate cache entry keyed on productId=456
});
// Applied to the endpoint:
// app.MapGet("/api/products/{productId}", GetProduct)
// .CacheOutput("ProductDetail");
// ════════════════════════════════════════════════════════════════════════════
// VARY BY HEADER
// ════════════════════════════════════════════════════════════════════════════
// Vary by Accept-Language when your endpoint returns localised content.
// Vary by Accept when your endpoint supports content negotiation (JSON vs XML).
// Do NOT vary by headers that do not affect the response body — each unique
// header value creates a separate cache entry.
options.AddPolicy("LocalisedContent", policy =>
{
policy
.Expire(TimeSpan.FromMinutes(30))
.VaryByHeader("Accept-Language");
// GET /api/content/homepage (Accept-Language: en-GB) → entry A
// GET /api/content/homepage (Accept-Language: fr-FR) → entry B
});
// What NOT to vary by:
// .VaryByHeader("Authorization") ← never — leaks authenticated responses
// to subsequent requests with a different token
// .VaryByHeader("User-Agent") ← almost never — fragments cache per browser version
// .VaryByHeader("X-Request-Id") ← never — unique per request, 0% hit rate
// ════════════════════════════════════════════════════════════════════════════
// VARY BY VALUE — custom key dimensions including per-user caching
// ════════════════════════════════════════════════════════════════════════════
// VaryByValue accepts a delegate that returns a KeyValuePair.
// The key and value are incorporated into the cache key.
// Use this for dimensions not covered by VaryByQuery/Header/Route.
options.AddPolicy("UserDashboard", policy =>
{
policy
.Expire(TimeSpan.FromSeconds(30))
.VaryByValue((context, cancellationToken) =>
{
// Vary by authenticated user ID — each user gets their own cache entry.
// If the user is not authenticated, fall through to an anonymous entry.
var userId = context.HttpContext.User
.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? "anonymous";
return ValueTask.FromResult(
new KeyValuePair("userId", userId));
});
// ⚠ Per-user caching multiplies entries by active user count.
// Only use for responses that are genuinely expensive to generate
// and stable enough to serve from cache for 30+ seconds.
// For truly personalised or sensitive data — account balances, PII —
// do not use output cache. Use IMemoryCache with a short sliding expiry
// or generate the response on every request.
});
// ════════════════════════════════════════════════════════════════════════════
// VARY-BY DECISION REFERENCE
// ════════════════════════════════════════════════════════════════════════════
//
// Dimension Method When to use
// ───────────────────── ────────────────────── ──────────────────────────────
// Query string param VaryByQuery("name") Pagination, filters, sorting
// Route segment VaryByRouteValue("id") Resource identity in URL path
// Request header VaryByHeader("name") Locale, content type
// Custom / user ID VaryByValue(delegate) Per-user, tenant, feature flag
// No variation needed (omit VaryBy*) Reference data, static content
The Authorization header warning in the code block is the most important line on the list. Varying by the Authorization header sounds like the right way to produce per-user cached responses — it is not. The Authorization value changes per session but the cache key based on it can collide in subtle ways, and more importantly, varying by Authorization does not prevent a cached response intended for one token from being served when a different token produces the same header value after a token rotation. Use VaryByValue with the authenticated user ID claim — a stable, meaningful identity value — not the raw token.
Safe TTLs: Matching Expiry to Data Freshness Requirements
TTL is a contract between your cache and your users: you are guaranteeing that the response they receive is no older than the TTL value. Setting a TTL longer than your data's acceptable staleness window produces stale data incidents — prices, inventory counts, or content that was updated but the cache has not expired yet. Setting a TTL shorter than necessary wastes the cache: every request misses, the handler runs, and the only overhead the cache added was the lookup. The right TTL is the longest value that keeps staleness within the tolerance the endpoint's data type supports.
builder.Services.AddOutputCache(options =>
{
// ── Reference / lookup data ───────────────────────────────────────────
// Countries, currencies, postal code formats, product categories.
// Changes only on application releases or admin updates.
// Safe TTL: 30–60 minutes. Burst the tag on admin save.
options.AddPolicy("ReferenceData", policy => policy
.Expire(TimeSpan.FromMinutes(60))
.Tag("reference"));
// ── Public catalogue / CMS content ───────────────────────────────────
// Product listings, category pages, marketing content.
// Changes on content publish events — can tolerate minutes of staleness.
// Safe TTL: 2–10 minutes. Tag-based invalidation on publish.
options.AddPolicy("Catalogue", policy => policy
.Expire(TimeSpan.FromMinutes(5))
.VaryByQuery("page", "pageSize", "category", "sort")
.Tag("catalogue"));
// ── Search results ────────────────────────────────────────────────────
// Results depend on live inventory and pricing — staleness is visible.
// Safe TTL: 10–30 seconds. Short enough to track fast-moving inventory.
options.AddPolicy("Search", policy => policy
.Expire(TimeSpan.FromSeconds(15))
.VaryByQuery("q", "page", "pageSize", "filters"));
// ── Aggregated metrics / dashboards ──────────────────────────────────
// Order counts, revenue summaries, analytics panels.
// Users expect near-real-time — not exact real-time.
// Safe TTL: 30–120 seconds.
options.AddPolicy("Metrics", policy => policy
.Expire(TimeSpan.FromSeconds(60))
.VaryByValue((context, ct) =>
{
// Vary by the authenticated user's organisation/tenant
var tenantId = context.HttpContext.User
.FindFirst("tid")?.Value ?? "default";
return ValueTask.FromResult(
new KeyValuePair("tenantId", tenantId));
}));
// ── Health / status endpoints ─────────────────────────────────────────
// Infrastructure health checks called by load balancers every few seconds.
// Very short TTL prevents thundering herd on multi-instance deployments.
// Safe TTL: 3–10 seconds.
options.AddPolicy("HealthCheck", policy => policy
.Expire(TimeSpan.FromSeconds(5)));
// ── Do NOT output-cache these endpoint types ──────────────────────────
//
// POST / PUT / DELETE / PATCH — mutating requests must never be cached.
// Output Cache skips non-GET/HEAD requests by default — verify this
// is not overridden in a custom IOutputCachePolicy implementation.
//
// Per-user transactional data — account balances, order history, cart.
// These are unique per user and change on every transaction. The cache
// entry count equals your active user count and the hit rate approaches
// zero. Use IMemoryCache with a sliding expiry or skip caching entirely.
//
// Responses containing Set-Cookie — Output Cache will not cache these
// by default (correct behaviour — do not override it).
//
// Responses with no-store or private Cache-Control headers —
// respect these signals; they exist for security and privacy reasons.
});
// ── Tag-based invalidation: burst the cache when data changes ─────────────
// Tags connect cache entries to the data they represent.
// When data changes, invalidate by tag — all entries with that tag expire.
public class CatalogueController : ControllerBase
{
private readonly IOutputCacheStore _cacheStore;
public CatalogueController(IOutputCacheStore cacheStore)
=> _cacheStore = cacheStore;
[HttpPost("products")]
public async Task CreateProduct(
CreateProductRequest request,
CancellationToken cancellationToken)
{
// ... persist the product ...
// Invalidate all cache entries tagged "catalogue" —
// next request regenerates fresh catalogue pages.
await _cacheStore.EvictByTagAsync("catalogue", cancellationToken);
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}
}
Tag-based invalidation changes the TTL calculus significantly. Without invalidation, your TTL must be short enough that stale data self-corrects within an acceptable window — which pushes TTLs down and reduces hit rates. With tag-based invalidation, you can use longer TTLs because you have a mechanism to burst stale entries the moment data changes. A catalogue policy with a 5-minute TTL and tag invalidation on every product save means the cache is fresh immediately after any change, and hits a 5-minute cache between changes. The same policy without invalidation means users may see stale data for up to 5 minutes after a change — which may or may not be acceptable depending on the endpoint.
Debugging: Verifying the Cache Is Doing What You Expect
Output Cache failures — wrong responses, unexpected misses, stale data — are difficult to diagnose without visibility into what the middleware is actually deciding. The middleware does not add response headers indicating hit or miss by default. The computed cache key is not exposed in the response. Verifying correct behaviour requires either enabling debug logging, adding diagnostic middleware, or reading response timing as a proxy for cache hits. All three techniques are useful at different stages of development and investigation.
// ════════════════════════════════════════════════════════════════════════════
// TECHNIQUE 1: Enable Output Cache debug logging
// ════════════════════════════════════════════════════════════════════════════
// In Program.cs — development only:
builder.Logging.AddFilter(
"Microsoft.AspNetCore.OutputCaching",
LogLevel.Debug);
// The middleware logs:
// dbug: Serving response from cache. ← cache hit
// dbug: No cached response found. ← cache miss
// dbug: Response stored in cache. ← new entry written
// dbug: Response is not cacheable. ← request skipped (POST, Set-Cookie, etc.)
//
// Each log entry includes the request path and the computed cache key.
// Read the key carefully — if it does not include your vary-by dimensions,
// the policy is not applied to the endpoint or the middleware order is wrong.
// In appsettings.Development.json (alternative to code-based filter):
// {
// "Logging": {
// "LogLevel": {
// "Microsoft.AspNetCore.OutputCaching": "Debug"
// }
// }
// }
// ════════════════════════════════════════════════════════════════════════════
// TECHNIQUE 2: Diagnostic response headers in development
// ════════════════════════════════════════════════════════════════════════════
// Add middleware that appends cache status to response headers.
// Gate it on the development environment — never expose cache internals in production.
app.Use(async (context, next) =>
{
// Capture the response start so we can add headers before the body is written
context.Response.OnStarting(() =>
{
// The Output Cache middleware sets this feature when it serves from cache
var cacheFeature = context.Features.Get();
if (cacheFeature is not null)
{
context.Response.Headers["X-Cache-Status"] =
context.Response.Headers.ContainsKey("Age") ? "HIT" : "MISS";
}
return Task.CompletedTask;
});
await next(context);
});
// With this middleware in place:
// curl -I http://localhost:5000/api/products?page=1
# HTTP/1.1 200 OK
# X-Cache-Status: MISS ← first request — response generated and cached
# Content-Type: application/json
# curl -I http://localhost:5000/api/products?page=1
# HTTP/1.1 200 OK
# X-Cache-Status: HIT ← second request — served from cache
# Age: 3 ← cached 3 seconds ago
// ════════════════════════════════════════════════════════════════════════════
// TECHNIQUE 3: Verify vary-by dimensions are working
// ════════════════════════════════════════════════════════════════════════════
// Run these requests in sequence and verify the responses differ correctly:
// Test 1: confirm query string varies the key
// curl http://localhost:5000/api/products?page=1 → products page 1
// curl http://localhost:5000/api/products?page=2 → products page 2 (not page 1)
// If both return page 1: VaryByQuery("page") is missing or policy not applied.
// Test 2: confirm unrelated query params do NOT vary the key
// curl http://localhost:5000/api/products?page=1
// curl http://localhost:5000/api/products?page=1&utm_source=email
// Both should return identical responses with the same Age header.
// If Age resets on the second request: utm_source is being included in the key
// (only possible if VaryByQuery() is called with no arguments — avoid this).
// Test 3: confirm tag invalidation works end-to-end
// 1. GET /api/products?page=1 → MISS, response cached, Age starts
// 2. GET /api/products?page=1 → HIT, Age > 0
// 3. POST /api/products (create) → triggers EvictByTagAsync("catalogue")
// 4. GET /api/products?page=1 → MISS, cache entry was evicted, fresh response
// ════════════════════════════════════════════════════════════════════════════
// COMMON REASONS A RESPONSE IS NOT BEING CACHED
// ════════════════════════════════════════════════════════════════════════════
//
// 1. Request method is not GET or HEAD
// Output Cache skips POST/PUT/DELETE/PATCH by default.
//
// 2. Response sets a Set-Cookie header
// Output Cache will not store responses that set cookies.
//
// 3. Response Cache-Control is private or no-store
// Output Cache respects these directives.
//
// 4. UseOutputCache() is in the wrong position in the pipeline
// Must be after UseRouting() and UseAuthentication().
//
// 5. The endpoint has no [OutputCache] attribute or .CacheOutput() call
// Output Cache is opt-in per endpoint — it does not cache everything by default.
//
// 6. An exception was thrown during response generation
// Failed responses are never stored.
The "common reasons a response is not being cached" list at the bottom of the code block is the first place to look when debug logging shows "Response is not cacheable" for a request you expected to be cached. Middleware pipeline order accounts for a significant fraction of "nothing is being cached" investigations — UseOutputCache() placed before UseRouting() means route values are not yet available when the cache key is computed, and the middleware may skip caching rather than produce an incorrect key. Move it after routing, re-test, and read the debug log again before looking elsewhere.