Output caching (introduced in ASP.NET Core 7) caches entire HTTP responses — the complete JSON body, headers, and status code — before they leave the middleware pipeline. Unlike IMemoryCache (where you cache individual objects and still run the controller to build the response), output caching short-circuits at the middleware level: cached responses bypass the controller entirely, returning the stored response in microseconds. It is the highest-leverage caching approach for read-heavy, non-personalised API endpoints.
Output Cache Configuration
// ── Registration ──────────────────────────────────────────────────────────
builder.Services.AddOutputCache(opts =>
{
// Default policy: 60-second cache
opts.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(60)));
// Named policy for public post lists
opts.AddPolicy("PublishedPosts", policy =>
policy
.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("page", "size", "category") // separate cache per page/filter
.Tag("posts") // for group invalidation
.SetLocking(true)); // prevent stampede
// Named policy for individual posts
opts.AddPolicy("PostDetail", policy =>
policy
.Expire(TimeSpan.FromMinutes(10))
.SetVaryByRouteValue("id") // separate cache per post ID
.Tag("posts", "post-detail"));
});
app.UseOutputCache(); // must be after UseRouting, before MapControllers
// ── Apply to controller actions ───────────────────────────────────────────
[HttpGet]
[OutputCache(PolicyName = "PublishedPosts")]
public async Task<ActionResult<PagedResult<PostSummaryDto>>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int size = 10,
[FromQuery] string category = "",
CancellationToken ct = default)
=> Ok(await _service.GetPublishedAsync(page, size, category, ct));
// ← This action body only runs on a cache MISS
// On a cache HIT, the stored response is returned immediately from middleware
[HttpGet("{id:int}")]
[OutputCache(PolicyName = "PostDetail")]
public async Task<ActionResult<PostDto>> GetById(int id, CancellationToken ct)
{
var post = await _service.GetByIdAsync(id, ct);
return post is null ? NotFound() : Ok(post);
}
// ── Programmatic cache invalidation by tag ─────────────────────────────────
public class PostService(
IPostRepository repo,
IOutputCacheStore outputCacheStore) : IPostService
{
public async Task PublishAsync(int id, CancellationToken ct)
{
await repo.PublishAsync(id, ct);
// Invalidate all cached responses tagged "posts"
await outputCacheStore.EvictByTagAsync("posts", ct);
// Invalidate the specific post's cached response
await outputCacheStore.EvictByTagAsync($"post:{id}", ct);
}
}
IMemoryCache for API endpoints because it bypasses the entire MVC pipeline: no model binding, no controller instantiation, no service calls, no JSON serialisation. The trade-off is granularity — you cache full responses, not individual objects. This makes it ideal for list endpoints and read endpoints, but unsuitable for personalised responses.Tag method to group related cache entries for coordinated invalidation. Tag all post-related cache entries with "posts". When a post is published or deleted, call outputCacheStore.EvictByTagAsync("posts") to invalidate every post list, post detail, and search result in one call. Without tags, you would need to track every specific cache key that might contain post data — fragile and error-prone at scale.IMemoryCache with user-specific keys instead. Output cache is for public, non-personalised responses only — use [AllowAnonymous] as a reminder signal alongside [OutputCache].Output Cache vs Response Cache
| Feature | Output Cache (ASP.NET Core 7+) | Response Cache ([ResponseCache] attribute) |
|---|---|---|
| Storage location | Server-side (memory or distributed) | Client/proxy-side (HTTP Cache-Control headers) |
| Cache control | Full programmatic control | HTTP caching headers only |
| Tag-based invalidation | ✅ Yes | No |
| Appropriate for | Public API endpoints | Static assets, CDN responses |
| Bypass on auth | Configurable | No (must set NoStore for auth) |
Common Mistakes
Mistake 1 — Applying [OutputCache] to authenticated endpoints (user data leakage)
❌ Wrong — User A’s profile response cached and served to User B on the same URL.
✅ Correct — only apply output caching to [AllowAnonymous] endpoints returning non-personalised data.
Mistake 2 — Not using SetVaryByQuery for filtered list endpoints (all pages return same cached result)
❌ Wrong — /posts?page=1 and /posts?page=2 return the same cached first-page response.
✅ Correct — policy.SetVaryByQuery("page", "size", "category") creates separate cache entries per parameter combination.