Output Caching

Cache whole HTTP responses on the server with AddOutputCache and CacheOutput policies, vary keys correctly and evict by tag on writes.

Syntaxbuilder.Services.AddOutputCache(o => o.AddPolicy("Products", p => p.Expire(TimeSpan.FromSeconds(30)).SetVaryByQuery("page").Tag("products"))); app.UseOutputCache(); app.MapGet("/products", List).CacheOutput("Products");

Output caching stores the complete response (status, headers and body) on the server and replays it for later requests with the same cache key, without running the endpoint. It is configured by the server, ignores the client's Cache-Control request headers, and is the right tool for anonymous, read-heavy endpoints such as catalogue lists. It is a different middleware from the older Response Caching middleware, which follows HTTP caching rules and lets any client bypass it with Cache-Control: no-cache.

Wiring it up

builder.Services.AddOutputCache(...) registers the store and policies; app.UseOutputCache() adds the middleware, after UseCors, after UseAuthentication and UseAuthorization, and, in controller apps, after UseRouting. The authentication order is a security rule: placed earlier, a cookie-authenticated request looks anonymous to the default policy, so its per-user response is stored by URL and served to other users, and a cached response skips any authorization that runs later. Nothing is cached until an endpoint opts in with .CacheOutput(), .CacheOutput("PolicyName") or [OutputCache], or a base policy applies. A base policy (AddBasePolicy) runs for every request, so scope it with With(...) or prefer named policies; .CacheOutput(p => p.NoCache()) opts an endpoint out.

Defaults

  • Only GET and HEAD requests and 200 responses are cached.
  • Requests from authenticated users, and responses that set cookies, are not cached.
  • Expiry is 60 seconds (DefaultExpirationTimeSpan); total size 100 MB; bodies over 64 MB are not stored.
  • The key includes the path and every query string value; the path is case-insensitive.
  • Locking is on: concurrent requests for an uncached key wait for the first to produce the response.
  • The store is in-memory per instance; each pod caches separately and a restart empties it.

Varying the key

MethodKey varies by
SetVaryByQuery("page", "category")Only those query keys; others are ignored
SetVaryByHeader("Accept-Language")Header values
SetVaryByRouteValue("id")Route values
VaryByValue(ctx => ...)Any computed value, such as a tenant

Listing query keys is a two-edged choice. It stops ?utm_source=... from creating a new entry per campaign link, but if the handler also reads sort and you forgot to list it, every sort order gets whichever response was cached first.

Tags and eviction

Add .Tag("products") to a policy and call IOutputCacheStore.EvictByTagAsync("products", ct) after a write commits. With the default store, eviction affects only the pod that handled the write; other pods serve the old response until expiry, so keep expiry short or use a shared store (a Redis-backed store is available as a separate package).

What not to cache

Per-user data is the classic failure: /me/orders cached by URL alone returns the first user's orders to everyone. The default policy refuses authenticated requests for this reason; a custom IOutputCachePolicy that switches that off must add the user or tenant to the key with VaryByValues. Also avoid caching responses that depend on headers you do not vary by, error responses and anything with Set-Cookie.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Cache GET /catalog/products?page=N for 30 seconds and evict the 'products' tag when a price changes, cutting database reads on the home page by 95%.
  • Ignore utm_* tracking parameters in the cache key so a marketing email does not create thousands of identical cache entries.
  • Cache a tenant's price list per tenant claim, with a tag per tenant so one tenant's catalogue update does not flush everyone else's.

More examples

Controllers: [OutputCache] with a named policy

The attribute either names a policy registered in <code>AddOutputCache</code> or sets <code>Duration</code>, <code>VaryByQueryKeys</code> and <code>Tags</code> inline. Named policies keep expiry and tags in one place, which matters when an eviction call elsewhere has to match the tag. <code>NoStore = true</code> documents that an endpoint must never be cached, even if a base policy is added later.

Example · csharp
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOutputCache(o =>
    o.AddPolicy("Invoices", p => p.Expire(TimeSpan.FromMinutes(2)).SetVaryByRouteValue("year").Tag("invoice-reports")));

var app = builder.Build();
app.UseOutputCache();
app.MapControllers();
app.Run();

public sealed record InvoiceTotals(int Year, int Month, decimal Net, decimal Tax);

[ApiController]
[Route("api/reports/invoices")]
public sealed class InvoiceReportsController : ControllerBase
{
    [HttpGet("{year:int}")]
    [OutputCache(PolicyName = "Invoices")]
    public IEnumerable<InvoiceTotals> ByYear(int year) =>
        Enumerable.Range(1, 12).Select(m => new InvoiceTotals(year, m, 125_000m + m * 1_000m, 25_000m + m * 200m));

    [HttpGet("{year:int}/live")]
    [OutputCache(NoStore = true)]
    public InvoiceTotals CurrentMonth(int year) =>
        new(year, DateTime.UtcNow.Month, 131_400m, 26_280m);
}

A tenant-aware policy for authenticated catalogues

Implementing <code>IOutputCachePolicy</code> replaces the default rules, including the one that refuses authenticated requests, so the policy has to restate what it keeps: GET only, 200 only, no <code>Set-Cookie</code>. The tenant claim goes into <code>VaryByValues</code>, making it part of the key, and into a tag so a price change for one tenant evicts only that tenant's entries. Prices that differ per user rather than per tenant should not be output cached at all.

Example · csharp
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Primitives;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddAuthorization();
builder.Services.AddOutputCache(o => o.AddPolicy("TenantCatalog", new TenantCatalogPolicy()));

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();

app.MapGet("/catalog/price-list", () => TypedResults.Ok(new[] { new { sku = "SKU-1001", price = 42.00m } }))
    .RequireAuthorization()
    .CacheOutput("TenantCatalog");
app.Run();

public sealed class TenantCatalogPolicy : IOutputCachePolicy
{
    public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
    {
        var tenant = context.HttpContext.User.FindFirst("tenant_id")?.Value;
        var cacheable = HttpMethods.IsGet(context.HttpContext.Request.Method) && tenant is not null;

        context.EnableOutputCaching = true;
        context.AllowCacheLookup = cacheable;
        context.AllowCacheStorage = cacheable;
        context.AllowLocking = true;
        context.ResponseExpirationTimeSpan = TimeSpan.FromMinutes(1);
        if (cacheable)
        {
            // The tenant is part of the key: tenant A can never be served tenant B's prices.
            context.CacheVaryByRules.VaryByValues["tenant"] = tenant!;
            context.Tags.Add($"tenant:{tenant}:catalog");
        }
        return ValueTask.CompletedTask;
    }

    public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;

    public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
    {
        var response = context.HttpContext.Response;
        if (response.StatusCode != StatusCodes.Status200OK || !StringValues.IsNullOrEmpty(response.Headers.SetCookie))
            context.AllowCacheStorage = false;
        return ValueTask.CompletedTask;
    }
}

Global options, a scoped base policy and an opt-out

A base policy runs for every request the middleware sees, so an unscoped one quietly starts caching endpoints nobody reviewed. <code>With(...)</code> limits it to a path prefix, and <code>NoCache()</code> carves out an endpoint whose data must be live. <code>MaximumBodySize</code> keeps one large export from filling the store; responses over the limit are served normally and simply not cached.

Example · csharp
using Microsoft.AspNetCore.OutputCaching;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(options =>
{
    options.SizeLimit = 256L * 1024 * 1024;   // bytes for the whole store, default 100 MB
    options.MaximumBodySize = 1024 * 1024;    // do not store bodies over 1 MB, default 64 MB
    options.DefaultExpirationTimeSpan = TimeSpan.FromSeconds(20);

    // Applies to every request, so restrict it to the public catalogue paths.
    options.AddBasePolicy(p => p
        .With(ctx => ctx.HttpContext.Request.Path.StartsWithSegments("/catalog"))
        .Tag("catalog"));
});

var app = builder.Build();
app.UseOutputCache();

app.MapGet("/catalog/categories", () => TypedResults.Ok(new[] { "lighting", "furniture", "textiles" }));
app.MapGet("/catalog/stock/{sku}", (string sku) => TypedResults.Ok(new { sku, available = Random.Shared.Next(0, 40) }))
    .CacheOutput(p => p.NoCache());
app.Run();

Discussion

  • Be the first to comment on this lesson.