Learn ASP.NET Core output caching to dramatically speed up API responses. Complete tutorial with code examples, best practices, and pitfalls. Start now!
ASP.NET Core output caching is one of the fastest ways to make a slow API feel instant. If your endpoints repeatedly run the same database queries, serialize the same JSON, or recompute the same expensive results for every request, you are wasting CPU, memory, and money. In this tutorial, you will learn how output caching in ASP.NET Core works, how to configure it in .NET 8 and later, and the best practices and common pitfalls that separate a fast, reliable API from a buggy one that serves stale data.
Whether you are a beginner searching for "how to cache API responses in C#", an intermediate developer comparing response caching vs output caching, or a senior engineer tuning cache policies at scale, this guide has you covered with practical, runnable code.
What Is ASP.NET Core Output Caching?
Output caching stores the full HTTP response generated by your server and replays it for subsequent matching requests — without re-executing your controller action, minimal API handler, or middleware pipeline. Introduced as first-class middleware in .NET 7 and significantly improved in .NET 8, the output caching middleware lives entirely on the server, which gives you full control over when entries are stored and evicted.
This is a critical distinction. With output caching, you decide what gets cached and you can invalidate it on demand. Compare that to the older approach below.
Response Caching vs Output Caching: What's the Difference?
"Response caching vs output caching" is one of the most common questions developers ask, so let's clear it up:
- Response caching sets HTTP headers (like
Cache-Control) and relies on the client or downstream proxies to honor them. The server does not store anything. If a browser or CDN ignores the headers, you get no benefit, and you cannot forcibly invalidate a cached entry. - Output caching stores the response on the server. It works regardless of client behavior, supports programmatic invalidation via tags, and can cache responses even for authenticated requests when you configure it to.
For most APIs, ASP.NET Core output caching is the better choice because it is predictable and controllable. Use response caching headers in addition when you also want browsers and CDNs to cache.
How to Set Up Output Caching in ASP.NET Core
Getting started takes two lines in your Program.cs. You register the services with AddOutputCache and add the middleware to the pipeline with UseOutputCache.
var builder = WebApplication.CreateBuilder(args);
// 1. Register output caching services
builder.Services.AddOutputCache();
var app = builder.Build();
// 2. Add the middleware to the pipeline
app.UseOutputCache();
app.MapGet("/products", async (ProductService service) =>
{
var products = await service.GetAllAsync();
return Results.Ok(products);
})
.CacheOutput(); // 3. Opt this endpoint into caching
app.Run();
That's it. The first request hits your handler and executes the query; every request after that — for the default duration — is served instantly from the cache. The handler does not run again until the entry expires.
Why does this matter? An endpoint that took 200 ms to query and serialize now responds in well under a millisecond. Under load, that frees database connections and CPU for the requests that genuinely need fresh data.
Important: Middleware Order
A common pitfall is placing UseOutputCache() in the wrong position. It must come after UseRouting() (when used) and before the endpoints it should cache. If you also use CORS, call UseCors() before UseOutputCache(), otherwise CORS headers may be cached or missed incorrectly.
Configuring Cache Policies and Expiration
The default policy caches for a short period, but real applications need control. Define named policies in AddOutputCache and apply them per endpoint.
builder.Services.AddOutputCache(options =>
{
// A reusable named policy
options.AddPolicy("Expire60", policy =>
policy.Expire(TimeSpan.FromSeconds(60)));
// Change the default for all cached endpoints
options.AddBasePolicy(policy =>
policy.Expire(TimeSpan.FromSeconds(30)));
});
Apply a named policy by passing its name to CacheOutput:
app.MapGet("/products", GetProducts)
.CacheOutput("Expire60");
// Or configure inline without a named policy
app.MapGet("/categories", GetCategories)
.CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(5)));
On MVC and API controllers, use the attribute form instead:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
[OutputCache(PolicyName = "Expire60")]
public async Task<IActionResult> Get()
{
var products = await _service.GetAllAsync();
return Ok(products);
}
}
Varying the Cache by Query String, Headers, or Route
By default, output caching keys entries on the full request path and all query string values. But you often want to vary deliberately — for example, cache /products?page=1 separately from /products?page=2, while ignoring an irrelevant tracking parameter.
options.AddPolicy("PagedProducts", policy =>
policy.Expire(TimeSpan.FromMinutes(2))
.SetVaryByQuery("page", "pageSize") // vary only on these
.SetVaryByHeader("Accept-Language")); // vary on culture
Why this is essential: if you forget to vary by a meaningful parameter, different users will receive each other's cached data. If you vary by too much (like a unique session token), your hit rate collapses to zero and caching does nothing. Tune VaryBy carefully — it is the single most important correctness decision in caching.
Cache Invalidation with Tags
The hardest problem in caching is knowing when to throw data away. Output caching solves this elegantly with tags. Assign one or more tags to cached responses, then evict everything with a given tag in a single call when the underlying data changes.
app.MapGet("/products", GetProducts)
.CacheOutput(policy => policy
.Expire(TimeSpan.FromMinutes(10))
.Tag("products")); // tag this entry
// When a product is created or updated, evict the tag
app.MapPost("/products", async (
Product product,
ProductService service,
IOutputCacheStore cache,
CancellationToken ct) =>
{
await service.AddAsync(product);
// Invalidate every cached response tagged "products"
await cache.EvictByTagAsync("products", ct);
return Results.Created($"/products/{product.Id}", product);
});
This pattern — cache aggressively, then invalidate precisely on write — gives you the best of both worlds: blazing-fast reads and guaranteed-fresh data after every mutation. It is the cornerstone of high-performance ASP.NET Core APIs.
Scaling Output Caching Across Multiple Servers
By default, the output caching middleware stores entries in local server memory. That works perfectly for a single instance, but in a load-balanced or containerized environment each server keeps its own cache. Cache hit rates drop and an EvictByTagAsync call on one node does not clear the others.
For distributed scenarios, implement or plug in a shared IOutputCacheStore backed by Redis. A simplified Redis-backed registration looks like this:
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration =
builder.Configuration.GetConnectionString("Redis");
});
builder.Services.AddOutputCache();
With a shared store, every instance reads and writes the same cache and tag-based invalidation propagates everywhere. Always plan for this early if you intend to scale horizontally — retrofitting distributed caching is far harder than designing for it up front.
Best Practices for ASP.NET Core Output Caching
- Only cache idempotent GET requests. The middleware will not cache
POST,PUT, orDELETE, and you should never try to force it. - Never cache personalized or authenticated responses globally. If a response depends on the logged-in user, either skip caching or vary by a user-specific key — otherwise you will leak one user's data to another. This is a serious security pitfall.
- Set realistic expirations. Match the duration to how often the data actually changes. Reference data can cache for hours; a live inventory count for seconds.
- Pair short expirations with tag invalidation so reads stay fast and writes stay correct.
- Avoid caching huge or rarely repeated responses. Caching only pays off when the same response is served many times. A unique-per-request payload just wastes memory.
- Set a sensible size limit. Use
options.MaximumBodySizeandoptions.SizeLimitto prevent the cache from exhausting server memory. - Monitor your hit rate. A cache that is never hit is pure overhead; instrument it and verify it earns its keep.
Common Pitfalls to Avoid
- Forgetting to call
.CacheOutput(). Registering the middleware alone caches nothing — endpoints must opt in (unless you set a base policy that applies broadly). - Wrong middleware order. Place
UseOutputCache()after routing and CORS, before endpoints. - Over-varying the cache key. Varying by a unique value gives a 0% hit rate.
- Under-varying the cache key. Forgetting to vary by a meaningful parameter serves wrong data.
- Assuming the cache is shared. In-memory caches are per-instance; use Redis for multi-server deployments.
- Caching error responses. By default only
200 OKresponses are cached, which is what you want — don't override this without good reason.
Conclusion: Key Takeaways
ASP.NET Core output caching is a high-impact, low-effort optimization that can turn sluggish endpoints into sub-millisecond responses. By storing rendered responses on the server, it eliminates redundant database calls and serialization work, freeing your application to handle far more traffic on the same hardware.
Here are the key takeaways from this tutorial:
- Register with
AddOutputCacheand enable withUseOutputCache— then opt endpoints in with.CacheOutput(). - Prefer output caching over response caching when you need predictable, server-side, invalidatable caching.
- Use named policies to control expiration and
VaryByrules — gettingVaryByright is the most important correctness decision. - Invalidate precisely with tags and
EvictByTagAsyncso your data stays fresh after writes. - Use a Redis-backed
IOutputCacheStoreto scale caching across multiple servers. - Never cache personalized data globally — it is a real security risk.
Start small: add output caching to your single busiest read endpoint, measure the latency drop, then expand from there. With a few lines of code and the best practices above, you can dramatically speed up your ASP.NET Core API responses today.
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