Learn ASP.NET Core output caching with real C# examples. Speed up API responses dramatically with policies, cache invalidation & Redis. Start caching today!
If your API is hitting the database on every single request for data that barely changes, you are burning CPU, database connections, and money for nothing. ASP.NET Core output caching is one of the highest-impact, lowest-effort performance wins available in modern .NET: with a few lines of code, you can serve repeat requests in microseconds instead of milliseconds — often cutting response times by 90% or more and slashing database load dramatically.
Output caching was introduced in .NET 7 as a first-class middleware and has matured significantly through .NET 8 and .NET 9, gaining built-in Redis support, tag-based invalidation, and clean integration with Minimal APIs and MVC controllers. In this tutorial, you will learn how output caching works, how it differs from response caching (a distinction that confuses even senior developers), how to configure custom cache policies, how to invalidate stale entries, and how to scale caching across multiple servers with Redis.
What Is Output Caching in ASP.NET Core?
Output caching stores the complete generated response of an endpoint — status code, headers, and body — on the server. When a subsequent request matches a cached entry, the middleware returns the stored response immediately, without executing your endpoint handler, your EF Core queries, your serialization logic, or anything else downstream.
That last part is the key to understanding why it is so fast. A typical API request pipeline looks like this:
- Routing and endpoint selection
- Authentication and authorization
- Model binding and validation
- Business logic and database queries (usually the expensive part)
- JSON serialization
With a cache hit, everything from model binding onward is skipped. A request that normally takes 150ms because of a complex SQL query returns in under 1ms. Multiply that across thousands of requests per minute and you can often downsize your database tier — a real cost saving, not just a benchmark bragging right.
Output Caching vs Response Caching: What's the Difference?
This is one of the most common points of confusion, because ASP.NET Core has both a response caching middleware and an output caching middleware. They sound identical but behave very differently:
- Response caching is driven by HTTP headers (
Cache-Control,Vary). The server politely asks clients, proxies, and CDNs to cache the response. The client can ignore it — for example, browsers sendCache-Control: no-cacheon refresh, which busts the cache entirely. You have no server-side control over eviction. - Output caching is entirely server-controlled. It does not depend on the client honoring any headers, it works even when clients send
no-cache, and — crucially — it supports programmatic invalidation. When your data changes, you can evict the cache immediately.
The practical rule: use output caching for server-side performance, and use standard HTTP cache headers when you want browsers and CDNs to do the caching for you. They are complementary, not competing, but for API performance output caching is almost always the right tool.
How to Enable ASP.NET Core Output Caching (Step by Step)
Getting started takes three steps: register the services, add the middleware, and mark endpoints as cacheable. Here is a complete, runnable Minimal API example:
var builder = WebApplication.CreateBuilder(args);
// 1. Register output caching services
builder.Services.AddOutputCache(options =>
{
// Applies to every endpoint that opts in without a named policy
options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(30)));
// A named policy for rarely-changing lookup data
options.AddPolicy("Lookups", policy =>
policy.Expire(TimeSpan.FromHours(6)));
});
var app = builder.Build();
// 2. Add the middleware (after UseRouting/UseAuthorization if present)
app.UseOutputCache();
// 3. Opt endpoints in
app.MapGet("/api/products", async (ProductService service) =>
await service.GetProductsAsync())
.CacheOutput(); // uses the base policy: 30 seconds
app.MapGet("/api/countries", async (LookupService service) =>
await service.GetCountriesAsync())
.CacheOutput("Lookups"); // cached for 6 hours
app.Run();
For MVC controllers, use the [OutputCache] attribute instead:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly ProductService _service;
public ProductsController(ProductService service) => _service = service;
[HttpGet]
[OutputCache(Duration = 60)]
public async Task<IActionResult> GetAll()
=> Ok(await _service.GetProductsAsync());
[HttpGet("{id:int}")]
[OutputCache(PolicyName = "Lookups")]
public async Task<IActionResult> GetById(int id)
=> Ok(await _service.GetProductAsync(id));
}
To verify it is working, hit an endpoint twice and watch your logs: the second request should produce no database query and return dramatically faster. Add a timestamp to the response during testing and you will see it stay frozen until the cache expires.
Varying the Cache: Query Strings, Headers, and Route Values
By default, output caching automatically varies by the full query string — /api/products?category=books and /api/products?category=toys get separate cache entries. But real APIs often need finer control. Caching /api/products?page=1 and /api/products?page=2 as the same entry would serve wrong data; varying by an analytics parameter like utm_source would fragment your cache pointlessly.
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("ProductList", policy => policy
.Expire(TimeSpan.FromMinutes(5))
// Only these query parameters create distinct cache entries
.SetVaryByQuery("page", "pageSize", "category")
// Serve different cached responses per requested language
.SetVaryByHeader("Accept-Language"));
});
The why here matters: every distinct vary combination is a separate copy of the response in memory. Vary by too little and users see each other's data; vary by too much and your hit rate collapses while memory usage explodes. Be deliberate — list exactly the inputs that actually change the response body, and nothing else.
Cache Invalidation with Tags: Serving Fresh Data
Phil Karlton's famous line — "there are only two hard things in computer science: cache invalidation and naming things" — is famous because it is true. The biggest objection to caching is stale data, and this is where output caching shines over response caching: tag-based eviction lets you purge entries the moment data changes.
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Products", policy => policy
.Expire(TimeSpan.FromHours(1))
.Tag("products")); // label every entry from this policy
});
// Read endpoints: cached up to 1 hour
app.MapGet("/api/products", async (ProductService s) =>
await s.GetProductsAsync())
.CacheOutput("Products");
// Write endpoint: evict all "products" entries immediately
app.MapPost("/api/products", async (
Product product,
ProductService service,
IOutputCacheStore cache,
CancellationToken ct) =>
{
await service.CreateAsync(product);
await cache.EvictByTagAsync("products", ct);
return Results.Created($"/api/products/{product.Id}", product);
});
This pattern gives you the best of both worlds: aggressive one-hour caching for reads, yet zero staleness after writes. You can tag entries with multiple labels (for example, a global "products" tag plus a per-item $"product-{id}" tag) and evict at whatever granularity your writes require.
Scaling Out: Redis Output Cache for Multiple Servers
The default output cache store is in-process memory. That is perfect for a single server, but in a load-balanced deployment each instance maintains its own cache — hit rates drop, and eviction on one server does not propagate to the others. Since .NET 8, the fix is a one-liner with the official Redis package (Microsoft.AspNetCore.OutputCaching.StackExchangeRedis):
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "myapi:";
});
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromMinutes(5)));
});
Now all instances share one cache: a response generated on server A is served from cache by server B, and EvictByTagAsync purges entries cluster-wide. The trade-off is a network hop to Redis (typically 1–3ms) instead of a memory read, which is still far cheaper than regenerating the response. For most APIs this is an easy win the moment you run more than one instance.
Best Practices and Common Pitfalls
Best practices
- Cache the expensive, high-traffic, slow-changing endpoints first. Profile before caching everything — a product catalog read 10,000 times a minute is a great candidate; an admin report viewed twice a day is not.
- Pair short expirations with tag eviction. An expiry of hours plus immediate eviction on writes beats a nervous 10-second expiry that barely helps.
- Lean on the built-in stampede protection. When a popular entry expires, output caching automatically lets one request regenerate the response while concurrent requests wait for the result — you get thundering-herd protection for free, something hand-rolled
IMemoryCachecode frequently gets wrong. - Name your policies. Centralized, named policies (
"Lookups","ProductList") keep cache durations in one place instead of scattered magic numbers.
Pitfalls to avoid
- Never cache per-user data with a shared policy. By default, output caching refuses to cache authenticated requests — a deliberate safety rail. If you override this with a custom policy, you must vary by user identity, or user A will be served user B's account details. This is the single most dangerous caching mistake in production.
- Only GET and HEAD are cached by default. POST responses are not cached, and that is almost always correct — do not fight it.
- Set-Cookie responses are not cached. If an endpoint sets cookies (session middleware, anti-forgery), caching is skipped silently. If your hit rate is mysteriously zero, check for cookies first.
- Watch memory limits. The default in-memory store caps individual entries at 64MB total body size and the store at roughly 100MB; oversized responses silently bypass the cache. Tune
MaximumBodySizeandSizeLimitinOutputCacheOptionsif needed. - Do not confuse it with
IMemoryCache. Output caching caches whole HTTP responses;IMemoryCache/HybridCachecache arbitrary objects inside your code. Complex apps often use both at different layers.
Conclusion: Key Takeaways on ASP.NET Core Output Caching
ASP.NET Core output caching is the rare optimization that is both dramatic and simple: register the services, add app.UseOutputCache(), opt endpoints in with CacheOutput() or [OutputCache], and your hottest endpoints stop hammering the database on every request.
- Output caching is server-controlled — unlike response caching, it works regardless of client headers and supports programmatic eviction.
- Use
SetVaryByQueryandSetVaryByHeaderdeliberately: vary by exactly what changes the response, nothing more. - Tag-based eviction (
EvictByTagAsync) solves the stale-data problem — cache aggressively, evict on writes. - Add the Redis store the moment you run multiple instances, so all servers share one cache and evictions propagate everywhere.
- Respect the safety rails: authenticated requests, non-GET methods, and cookie-setting responses are excluded by default for good reasons.
Pick your slowest, busiest read endpoint, wrap it in a tagged policy today, and measure the before-and-after latency. Few afternoons of work will ever pay off faster.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment