Output & Response Caching
Key Points
- Output caching (.NET 7+) caches server-side response bytes, keyed on configurable inputs (path, query, headers, etc.). Configurable per endpoint with policies.
- Response caching middleware writes
Cache-Controlheaders and short-circuits if a downstream cache (CDN, browser) might serve the response. - They're complementary: output caching for server speed, response caching for downstream cache hints.
OutputCacheStorecan be in-memory or distributed (Redis). Output cache is shared across requests; response cache is per-instance.- Avoid caching authenticated/per-user responses without per-user cache keys.
Concepts (deep dive)
Output caching
builder.Services.AddOutputCache(o =>
{
o.AddBasePolicy(b => b.Cache()); // default: cache GET/HEAD with no auth
o.AddPolicy("ByQueryAndUser", b => b
.SetVaryByQuery("category", "page")
.SetVaryByHeader("X-Tenant-Id")
.Expire(TimeSpan.FromMinutes(5)));
});
app.UseOutputCache();
app.MapGet("/products", () => /* ... */).CacheOutput("ByQueryAndUser");
app.MapGet("/popular", () => /* ... */).CacheOutput(b => b.Expire(TimeSpan.FromMinutes(1)));
Output cache: - Stores the response body + headers server-side. - Keys on URL + configured varies (query, headers). - Default policy: skip authenticated requests, cache only GET/HEAD. - Can be evicted by tag (b.Tag("products")) and OutputCacheStore.EvictByTagAsync("products").
Distributed output cache
builder.Services.AddOutputCache();
builder.Services.AddStackExchangeRedisOutputCache(o => o.Configuration = "redis:6379");
Out-of-the-box Redis-backed cache. All replicas share state.
Response caching middleware
builder.Services.AddResponseCaching();
app.UseResponseCaching();
app.MapGet("/static-data", (HttpContext ctx) =>
{
ctx.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromMinutes(10)
};
ctx.Response.Headers.Vary = "Accept-Encoding";
return Results.Ok(/* ... */);
});
Sets Cache-Control: public, max-age=600 so CDNs and browsers can cache. The middleware also caches in-memory locally — a small win, but the bigger leverage is downstream.
💡 For static-ish data behind CDN, response caching is enough. For dynamic-ish data shared across users, output caching wins.
Tag-based eviction
app.MapGet("/products", () => /* ... */).CacheOutput(b => b.Tag("products"));
app.MapPost("/products", async (Product p, IOutputCacheStore store) =>
{
/* save */
await store.EvictByTagAsync("products", default);
return Results.Created();
});
When data changes, evict the tag → all entries with that tag are invalidated.
VaryByValue for custom keys
Keys responses by computed value — works for tenants, locales, feature flags.
SetCacheKeyPrefix
If multiple apps share the same Redis output cache:
Avoids collisions.
Common cache-control directives
| Directive | Meaning |
|---|---|
public | Any cache may store |
private | Only browser may store |
no-cache | Cache may store, but must revalidate |
no-store | No caching anywhere |
max-age=N | Cache for N seconds |
s-maxage=N | Same, but only for shared caches (CDN) |
must-revalidate | If stale, must validate before serving |
immutable | Resource never changes (long-cache assets) |
For static fingerprinted assets (CSS/JS with hash in filename): Cache-Control: public, max-age=31536000, immutable.
Don't cache mistakes
app.MapGet("/account", (HttpContext ctx) =>
{
var name = ctx.User.Identity?.Name; // per-user
return Results.Ok(name);
}).CacheOutput(); // ❌ default policy doesn't cache auth requests but be explicit
Default Cache() policy skips responses with auth cookies. Verify your policy and test with auth.
When response caching middleware skips
The middleware skips caching when: - Response is Set-Cookie-decorated (per-user state). - Authorization header present. - Cache-Control: no-store or private. - Method other than GET/HEAD. - Response status not "cacheable" by HTTP semantics (most non-2xx).
Cached entry size limits
builder.Services.AddOutputCache(o =>
{
o.SizeLimit = 100 * 1024 * 1024; // 100 MB total cache
o.MaximumBodySize = 1 * 1024 * 1024; // 1 MB per entry
});
In-memory output cache enforces these. Important to bound memory under heavy traffic.
Code: correct vs wrong
❌ Wrong: caching per-user data with no key
✅ Correct: vary by user
(Better: don't cache per-user dynamic data; or use a short TTL.)
❌ Wrong: forgetting tag-based eviction
app.MapGet("/products", h).CacheOutput(b => b.Expire(TimeSpan.FromMinutes(60)));
// New product added; users see stale list for up to an hour.
✅ Correct
.CacheOutput(b => b.Tag("products").Expire(TimeSpan.FromMinutes(60)));
// On mutate: store.EvictByTagAsync("products", ct);
❌ Wrong: long max-age on dynamic data
✅ Correct
ctx.Response.Headers.CacheControl = "public, max-age=300, s-maxage=900, stale-while-revalidate=600";
Design patterns for this topic
Pattern 1 — "Output cache for shared dynamic data"
- Intent: server-side cache keyed by query/header.
Pattern 2 — "Tag-based eviction on writes"
- Intent: invalidate related entries together.
Pattern 3 — "Response caching for downstream CDN"
- Intent: offload to CDN/browser.
Pattern 4 — "Distributed output cache for multi-replica apps"
- Intent: all replicas share cache state.
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| Output cache (in-memory) | Fast | Per-instance |
| Output cache (Redis) | Shared | Network hop |
| Response caching | CDN-friendly | Limited to GET/HEAD |
Per-action CacheOutput | Explicit | Sprinkled |
| Global policy | DRY | Harder to override |
When to use / when to avoid
- Use output caching for shared dynamic data (lists, popular items).
- Use response caching for static-ish data behind CDN.
- Avoid caching authenticated/per-user data without per-user keys.
- Avoid long max-age on data that changes frequently.
Interview Q&A
Q1. Difference between output caching and response caching? Output caches the response server-side; response caching adds Cache-Control headers for downstream caches (CDN, browser).
Q2. When does the default output cache policy NOT cache? For non-GET/HEAD methods or requests with auth headers (cookies, Authorization).
Q3. How do you invalidate a cached entry? Tag-based: b.Tag("products") then OutputCacheStore.EvictByTagAsync("products", ct).
Q4. How do you cache differently per query string? b.SetVaryByQuery("category", "page") — cache key includes those query values.
Q5. Distributed output cache provider? AddStackExchangeRedisOutputCache() for Redis-backed storage shared across replicas.
Q6. What does s-maxage do? Cache directive interpreted only by shared caches (CDN). Lets you have different TTLs for browser vs CDN.
Q7. What's stale-while-revalidate? Tells caches: serve stale up to N seconds while revalidating in background. Smooths over backend hiccups.
Q8. Can you cache POST responses? Output caching: yes, via custom policy. Response caching middleware: no (HTTP semantics: POST is non-cacheable by default).
Q9. What's immutable cache-control directive for? Tells browsers the resource never changes — combined with hashed filenames for ultra-long caching.
Q10. Why might output cache not work behind a load balancer? In-memory output cache is per-instance. Use Redis-backed for shared cache.
Gotchas / common mistakes
- ⚠️ Caching per-user data globally — wrong content served.
- ⚠️ Long TTL with no eviction — stale data forever.
- ⚠️
Authorizationheader silently skips caching. - ⚠️
Cache-Control: max-age=0in response caching — middleware won't cache. - ⚠️ Forgetting
MaximumBodySize— large responses bypass cache silently.