Read-heavy ASP.NET Core endpoints on IIS often burn the same SQL and serialization work on every hit. Output caching sits in the middleware pipeline, stores the full response for matching requests, and returns it without re-running your action or EF Core query. On a Windows app pool that is the difference between a steady CPU graph and spikes every time a dashboard refreshes.
Unlike browser Cache-Control alone, server output cache helps all clients share one computed payload. Used with clear vary rules and tag-based eviction, it fits shared and VPS IIS hosts where you control the app but not a separate cache tier. The takeaway is simple: cache anonymous or semi-static API shapes at the edge of your app, and invalidate by tag when SQL data changes.
#Enable output caching in .NET 10
Add the services and middleware once in Program.cs. Default size limits are enough for JSON APIs; raise them only if you cache larger payloads. Keep the middleware after routing and before endpoints so route data and HTTP method participate in the cache key.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(p => p.Expire(TimeSpan.FromSeconds(30)));
options.AddPolicy("ProductsList", p => p
.Expire(TimeSpan.FromMinutes(2))
.Tag("products")
.SetVaryByQuery("page", "pageSize", "q"));
});
var app = builder.Build();
app.UseOutputCache();
app.MapGet("/api/products", async (int? page, int? pageSize, string? q, AppDb db) =>
{
page ??= 1;
pageSize ??= 20;
var items = await db.Products.AsNoTracking()
.Where(p => q == null || p.Name.Contains(q))
.OrderBy(p => p.Id)
.Skip((page.Value - 1) * pageSize.Value)
.Take(pageSize.Value)
.ToListAsync();
return Results.Ok(items);
}).CacheOutput("ProductsList");
app.Run();
That policy varies by the query string values your UI actually sends. Without SetVaryByQuery, page=2 can incorrectly reuse page=1. Prefer explicit vary lists over caching every arbitrary query key.
#Policies that behave on IIS
IIS recycles, overlapping app domains during deploy, and multiple worker processes change how long an in-memory cache lives. Plan for that instead of treating output cache like a durable store.
- Keep TTLs short (30s–2m) for lists backed by SQL that change often; use longer TTLs only for true reference data.
- Tag every mutable resource group (products, prices, inventory) so writes can evict related entries in one call.
- Do not assume cache survives app pool recycle, web.config touch, or Web Deploy. Cold starts refill naturally; pair with preload only if you already rely on it for other reasons.
- On multi-worker app pools each worker has its own memory cache. Sticky sessions are irrelevant here—expect brief inconsistency across workers until TTLs align, or keep TTL low.
IIS Output Caching in the server feature set is separate from ASP.NET Core’s middleware. Prefer one layer. If IIS dynamic compression or kernel cache is on for the site, verify you are not stacking opaque server caches on top of app policies you cannot invalidate by tag.
#What not to cache
Skip output cache for responses tied to the signed-in user, anti-forgery flows, or anything that reads cookies or Authorization and branches on identity. Default policies already avoid caching authenticated requests in common setups; still mark sensitive endpoints with .CacheOutput(p => p.NoCache()) or omit caching entirely when the handler uses HttpContext.User.
POST/PUT/DELETE should not be cached. MapGet and other safe GETs are the sweet spot. If you return personalized fields inside an otherwise public DTO, split the endpoint or vary by a stable, non-secret dimension you control—not by raw cookie strings.
#Evict on write so IIS traffic stays correct
TTL-only designs serve stale JSON until expiry. After a successful update, evict by tag from the same process that wrote to SQL Server.
app.MapPost("/api/products", async (
ProductDto dto,
AppDb db,
IOutputCacheStore cache,
CancellationToken ct) =>
{
var entity = new Product { Name = dto.Name, Price = dto.Price };
db.Products.Add(entity);
await db.SaveChangesAsync(ct);
// Drop all entries tagged "products" (lists, searches, etc.)
await cache.EvictByTagAsync("products", ct);
return Results.Created($"/api/products/{entity.Id}", entity);
});
Call EvictByTagAsync only after SaveChangesAsync succeeds. If the transaction rolls back, leave the cache alone. For batch imports, evict once at the end rather than per row.
Optional check on the host: after deploy, hit a cached GET twice and confirm the second response is faster in Failed Request Tracing or your app metrics, then POST an update and confirm the next GET reflects SQL. That validates policy keys and tag eviction under the real IIS worker identity.
Practical takeaway: add AddOutputCache with named policies that Expire, Tag, and SetVaryByQuery for your list endpoints; put UseOutputCache in the pipeline; cache only safe GETs; evict by tag after SQL writes. On IIS, treat the cache as a short-lived per-worker speedup—not a distributed store—and you cut repeat database load without serving the wrong page of results after a recycle or deploy.
Comments
No comments yet