Skip to main content

ASP.NET Core Output Caching: Speed Up Your API Fast

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 send Cache-Control: no-cache on 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 IMemoryCache code 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 MaximumBodySize and SizeLimit in OutputCacheOptions if needed.
  • Do not confuse it with IMemoryCache. Output caching caches whole HTTP responses; IMemoryCache/HybridCache cache 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 SetVaryByQuery and SetVaryByHeader deliberately: 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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...