Learnixo
Back to blog
AI Systemsintermediate

Output Caching in ASP.NET Core — Caching HTTP Responses

ASP.NET Core output caching: caching full HTTP responses, vary-by rules, cache policies, tag-based invalidation from handlers, and when to use output cache vs data cache.

Asma Hafeez KhanMay 16, 20264 min read
Output CacheCachingASP.NET Core.NETPerformance
Share:𝕏

Output Cache vs Data Cache

Data cache (IMemoryCache, IDistributedCache, HybridCache):
  Caches objects in memory
  You decide when to use the cached value
  Fine-grained control over what is cached
  Use for: business objects, query results, expensive computations

Output cache:
  Caches the full HTTP response (status, headers, body)
  Applied at the middleware level — handler does not run on cache hit
  Coarser but simpler — no serialization code needed
  Use for: public read-only endpoints that return the same response
           for a given URL/query combination

Setup

C#
// Program.cs
builder.Services.AddOutputCache(options =>
{
    // Default policy for all cached endpoints
    options.AddBasePolicy(policy =>
        policy.Expire(TimeSpan.FromMinutes(5)));

    // Named policies
    options.AddPolicy("FormulaeryCachePolicy", policy =>
        policy.Expire(TimeSpan.FromHours(4))
              .SetVaryByQuery("hospitalId")
              .Tag("formulary"));

    options.AddPolicy("PatientListPolicy", policy =>
        policy.Expire(TimeSpan.FromMinutes(2))
              .SetVaryByQuery("page", "pageSize", "department")
              .SetVaryByHeader("Authorization")  // different cache per user
              .Tag("patients"));
});

app.UseOutputCache();  // must be after UseRouting, before endpoints

Applying to Endpoints

C#
// Apply named policy
app.MapGet("/formulary", GetFormulary)
    .CacheOutput("FormularyCachePolicy");

// Inline policy
app.MapGet("/drugs", GetDrugs)
    .CacheOutput(policy =>
        policy.Expire(TimeSpan.FromHours(2))
              .Tag("drugs"));

// Disable for specific endpoints (override base policy)
app.MapPost("/prescriptions", AddPrescription)
    .DisableOutputCache();  // mutations should never be cached

Vary-By Rules

Output cache serves the same cached response to all requests unless you vary by something:

C#
options.AddPolicy("DepartmentList", policy =>
    policy
        .Expire(TimeSpan.FromMinutes(10))

        // Different cache entry per query string value
        .SetVaryByQuery("department", "sortBy")

        // Different cache entry per header value
        // (e.g., different languages via Accept-Language)
        .SetVaryByHeader("Accept-Language")

        // Different cache entry per route value
        .SetVaryByRouteValue("hospitalId")

        // Different cache entry per user (claim)
        // — only use when the response truly differs per user
        .SetVaryByValue(ctx =>
        {
            var userId = ctx.Request.HttpContext.User
                .FindFirstValue(JwtRegisteredClaimNames.Sub);
            return new ValueTask<string>(userId ?? "anonymous");
        }));

Tag-Based Invalidation from Code

When data changes, evict related cached responses:

C#
// In your mutation handler — inject IOutputCacheStore
public sealed class UpdateFormularyHandler
{
    private readonly DrugRepository    _repo;
    private readonly IOutputCacheStore _outputCache;

    public async Task<Result> Handle(
        UpdateFormularyCommand cmd, CancellationToken ct)
    {
        // ... update in DB

        // Invalidate all cached responses tagged "formulary"
        await _outputCache.EvictByTagAsync("formulary", ct);

        return Result.Success();
    }
}

Output Cache vs Cache-Control Headers

Cache-Control (browser caching):
  Caches the response in the client's browser or CDN
  The server has no control over eviction
  Client requests a new response when Cache-Control expires
  CDN-level: applies when using Azure Front Door, Cloudflare, etc.

Output Cache (server-side):
  Caches the response in the server's memory
  You control eviction with tags
  Client always hits your server but gets cached response
  Does not interact with browser cache

Locking and Stampede Protection

Output cache has built-in request queuing:

C#
options.AddBasePolicy(policy =>
    policy.Expire(TimeSpan.FromMinutes(5)));

// Default behavior: when the cache misses, only ONE request runs the handler
// Other concurrent requests wait for the first response, then serve the cached result
// This is built-in stampede protection — no SemaphoreSlim needed

What NOT to Cache with Output Cache

Do not cache:
  ✗ POST/PUT/DELETE responses — mutations should never be cached
  ✗ Responses with user-specific data unless Vary-By-User is set
  ✗ Real-time data (patient vitals, current drug orders in progress)
  ✗ Paginated results where the data changes frequently
  ✗ Authenticated endpoints without proper Vary-By-Header("Authorization")

Cache safely:
  ✓ Public read-only endpoints (formulary, ICD codes, hospital info)
  ✓ Reference data that changes on admin action (trigger eviction)
  ✓ Listing endpoints with stable filter combinations
  ✓ Static report generation (once generated, same for TTL)

Production issue I've seen: A team cached a /patients list response that included the current user's name in a sidebar. With output caching and no SetVaryByHeader("Authorization"), all users saw the first user's name in the sidebar. The fix: either exclude user-specific data from cached responses, or vary by the Authorization header.


Distributed Output Cache

C#
// Default: in-memory output cache (per instance)
// For multi-instance: use Redis-backed output cache
builder.Services.AddStackExchangeRedisOutputCache(options =>
    options.Configuration = builder.Configuration.GetConnectionString("Redis"));

// Now output cache entries are shared across all instances
// Eviction via EvictByTagAsync removes from all instances via Redis pub/sub

Output Cache + Response Compression

C#
// Add both — output cache stores the compressed response
builder.Services.AddResponseCompression(options =>
    options.EnableForHttps = true);

// Middleware order:
app.UseResponseCompression();   // before output cache
app.UseOutputCache();

Key Takeaway

Output caching caches complete HTTP responses at the middleware level — the handler does not run on a cache hit. It is simpler than data caching for endpoints that return the same response to many users. Use vary-by rules to ensure different users/queries get separate cache entries. Use tag-based invalidation to evict cached responses when the underlying data changes. For multi-instance, use Redis-backed output cache. Never cache mutation endpoints.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.