ASP.NET Core Output Cache: Vary-By Rules, Cache Key Design & TTL Strategies That Stay Correct

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.

Program.cs — Output Cache Registration & Named Policy Definitions
// ── 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.

VaryByRules.cs — Query, Route, Header, Value & The Per-User Pattern
// ════════════════════════════════════════════════════════════════════════════
// 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.

TtlPolicy.cs — TTL Selection by Endpoint Data Type
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.

OutputCacheDebugging.cs — Logging, Diagnostic Headers & Verification Patterns
// ════════════════════════════════════════════════════════════════════════════
// 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.

What Developers Want to Know

Why is ASP.NET Core Output Cache serving the same response regardless of query string?

Because query string parameters are not included in the cache key by default. The middleware uses only the request path as the base key unless you explicitly declare which parameters vary the cache. If your endpoint returns different results based on ?page=2 or ?category=shoes but you have not called VaryByQuery, every variant hits the same cache entry and gets the first cached response. Fix this by adding .VaryByQuery("page", "category") to your policy, naming only the parameters that actually change the response.

Can ASP.NET Core Output Cache serve user-specific responses safely?

Yes, with explicit configuration. The middleware does not automatically vary by authenticated user — by default, a cached response for one user is returned to all users hitting the same endpoint. Use VaryByValue with a delegate that extracts the authenticated user's ID claim to produce per-user cache entries. Be aware that per-user caching multiplies cache entries by your active user count — only appropriate for responses that are expensive to generate and relatively stable per user. For sensitive or transactional data, do not use Output Cache.

What is the difference between Output Cache and Response Cache in ASP.NET Core?

Response Cache is header-driven — it respects and sets HTTP cache-control headers and relies on the client and intermediary proxies to honour them. It cannot serve cached content if the client sends Cache-Control: no-cache. Output Cache (introduced in .NET 7) is server-side only — it caches on the server regardless of client cache-control headers and gives you full programmatic control over cache keys, vary-by rules, TTLs, and tag-based invalidation. Output Cache is the correct choice when you want predictable server-side caching behaviour independent of client or CDN semantics.

How do I choose the right TTL for an Output Cache policy?

TTL should match the acceptable staleness window for the data the endpoint returns. Reference data can tolerate 30–60 minutes. Public catalogue content: 2–10 minutes. Search results: 10–30 seconds. Aggregated metrics: 30–120 seconds. With tag-based invalidation you can use longer TTLs because stale entries are burst the moment data changes — the TTL becomes a safety net rather than the primary freshness mechanism. Do not output-cache per-user transactional data or responses containing Set-Cookie headers at all.

How do I debug what ASP.NET Core Output Cache is actually caching?

Enable debug logging for the Output Cache namespace: builder.Logging.AddFilter("Microsoft.AspNetCore.OutputCaching", LogLevel.Debug). The middleware logs every cache hit, miss, store, and skip decision with the computed cache key. In development, add diagnostic middleware that appends an X-Cache-Status: HIT/MISS response header so you can verify cache behaviour with curl -I. The most common reasons a response is not cached: wrong middleware pipeline order, non-GET request method, response sets Set-Cookie, or the endpoint has no .CacheOutput() call.

Back to Articles