.NET & C# Development · Lesson 63 of 92

Output Caching — Serve Responses Without Touching Your Code

What Is Output Cache?

Output Cache sits in the middleware pipeline and caches the complete HTTP response — status code, headers, and body. On a cache hit, the request never reaches your controller. Zero allocations, zero DB calls, zero business logic execution.

Introduced in .NET 7. Distinct from the older Response Caching middleware (which relied on HTTP cache headers and didn't support programmatic invalidation).


Setup

C#
// Program.cs
builder.Services.AddOutputCache(options =>
{
    // Global default — cache all GET/HEAD 200 responses for 60s
    options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(60)));
});

// ...

var app = builder.Build();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.UseOutputCache(); // must come after auth, before endpoint mapping

app.MapControllers();

UseOutputCache() must be placed after authentication/authorization middleware — so the cache doesn't accidentally serve an authenticated response to an anonymous user.


The [OutputCache] Attribute

Apply to controllers or individual actions:

C#
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    // Cache for 5 minutes, vary by the 'category' query param
    [HttpGet]
    [OutputCache(Duration = 300, VaryByQueryKeys = ["category", "page", "pageSize"])]
    public async Task<IActionResult> GetProducts(
        [FromQuery] string? category,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20,
        CancellationToken ct = default)
    {
        // Only reached on cache miss
        var products = await productService.GetProductsAsync(category, page, pageSize, ct);
        return Ok(products);
    }

    // Vary by route value
    [HttpGet("{id:int}")]
    [OutputCache(Duration = 600, VaryByRouteValueNames = ["id"])]
    public async Task<IActionResult> GetProduct(int id, CancellationToken ct = default)
    {
        var product = await productService.GetProductAsync(id, ct);
        return product is null ? NotFound() : Ok(product);
    }
}

Vary-By Rules

Output Cache creates a unique cache entry per combination of vary-by values.

C#
// Vary by query string parameter
[OutputCache(VaryByQueryKeys = ["sort", "filter"])]

// Vary by route value
[OutputCache(VaryByRouteValueNames = ["id", "version"])]

// Vary by request header
[OutputCache(VaryByHeaderNames = ["Accept-Language", "Accept-Encoding"])]

In minimal API:

C#
app.MapGet("/api/products", async (IProductService svc) =>
{
    return await svc.GetAllAsync();
})
.CacheOutput(policy => policy
    .Expire(TimeSpan.FromMinutes(5))
    .VaryByQuery("category", "page")
    .VaryByHeader("Accept-Language"));

Named Cache Policies

Define reusable policies in setup, reference them by name:

C#
builder.Services.AddOutputCache(options =>
{
    // Short-lived, public data
    options.AddPolicy("short", policy =>
        policy.Expire(TimeSpan.FromSeconds(30))
              .VaryByQuery("*")); // vary by all query params

    // Long-lived product catalogue
    options.AddPolicy("catalogue", policy =>
        policy.Expire(TimeSpan.FromHours(1))
              .VaryByQuery("category", "page", "pageSize")
              .Tag("products")); // tag for invalidation

    // Per-user — do NOT use with anonymous output cache
    options.AddPolicy("user-specific", policy =>
        policy.Expire(TimeSpan.FromMinutes(5))
              .VaryByValue((ctx, _) =>
              {
                  var userId = ctx.User.FindFirst("sub")?.Value ?? "anonymous";
                  return new KeyValuePair<string, string>("userId", userId);
              }));
});

Apply by name:

C#
[HttpGet]
[OutputCache(PolicyName = "catalogue")]
public async Task<IActionResult> GetCatalogue(CancellationToken ct)
{
    // ...
}

Or in minimal API:

C#
app.MapGet("/api/catalogue", GetCatalogue).CacheOutput("catalogue");

Tag-Based Invalidation

Tags let you invalidate a group of cache entries with one call — without knowing the individual cache keys.

C#
// Tag entries at policy or attribute level
[OutputCache(Duration = 600, Tags = ["products", "catalogue"])]

// Or in policy:
options.AddPolicy("products", policy =>
    policy.Expire(TimeSpan.FromMinutes(10))
          .Tag("products"));

Invalidate all entries with the products tag when data changes:

C#
public class ProductsController(IOutputCacheStore cacheStore) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> CreateProduct(
        CreateProductRequest request, CancellationToken ct)
    {
        var product = await productService.CreateProductAsync(request, ct);

        // Bust all cached product responses
        await cacheStore.EvictByTagAsync("products", ct);

        return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> UpdateProduct(
        int id, UpdateProductRequest request, CancellationToken ct)
    {
        await productService.UpdateProductAsync(id, request, ct);
        await cacheStore.EvictByTagAsync("products", ct);
        return NoContent();
    }
}

Redis as the Backing Store

By default, Output Cache stores entries in memory. For multi-instance deployments:

Bash
dotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis
C#
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "OutputCache:";
});

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromMinutes(5)));
});

All instances share the same Redis cache — consistent responses across the fleet.


Locking and Stampede Protection

Output Cache has built-in request coalescing. When a cache entry expires and multiple concurrent requests arrive simultaneously, only one request hits the origin. The others wait and receive the same response once it's cached.

This is automatic — no SemaphoreSlim needed, unlike IMemoryCache.


Output Cache vs Response Cache vs IMemoryCache

| | Output Cache | Response Cache Middleware | IMemoryCache | |---|---|---|---| | Caches | Full HTTP response | Full HTTP response | Any object | | .NET version | 7+ | All | All | | Programmatic invalidation | Yes (tags) | No | Yes (Remove) | | Distributed (Redis) | Yes | No | No (use IDistributedCache) | | Vary-by rules | Flexible | HTTP headers only | Manual key design | | Bypass for auth | Automatic (with UseOutputCache position) | Manual | Manual | | Best for | Public/semi-public API responses | Public HTTP caching with CDN | Business logic, computed values |

Do not use Output Cache for authenticated per-user responses unless you vary by user ID — a cache miss for one user must not serve another user's data.


Disabling Cache for Specific Requests

C#
// In middleware or a filter — disable cache for this request
app.Use(async (context, next) =>
{
    if (context.Request.Headers.ContainsKey("X-No-Cache"))
    {
        var feature = context.Features.Get<IOutputCacheFeature>();
        feature?.Forbid();
    }
    await next(context);
});

Key Takeaways

  • Output Cache caches the full HTTP response at the middleware level — zero controller overhead on hits
  • Tag entries on write, invalidate by tag on mutations — clean cache-aside at the HTTP layer
  • UseOutputCache() must go after auth middleware
  • Redis backing store makes output cache work across multiple instances
  • Use Output Cache for read-heavy public or semi-public endpoints; use IMemoryCache for objects in service layer logic