Output Caching — ASP.NET Core 7+ Response Caching

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);
    }
}
Note: Output caching stores the full HTTP response (serialised JSON + headers) — there is no deserialization or DTO mapping on a cache hit. This is significantly faster than 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.
Tip: Use the 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.
Warning: Never apply output caching to authenticated/personalised endpoints. If two users request the same URL and the first user’s response is cached, the second user receives the first user’s personalised data. Output caching is only safe for responses that are identical for all requestors. For authenticated endpoints, use 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.

🧠 Test Yourself

An output-cached endpoint returns posts tagged with “posts”. A new post is published. How do you ensure the next request returns fresh data?