Skip to main content

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

Learn ASP.NET Core output caching with practical C# examples. Cache API responses, set policies, vary by query, and evict cache tags. Start speeding up your API today.

If your API is doing the same database query hundreds of times a minute to return the same JSON, you're wasting CPU, wasting money, and making users wait. ASP.NET Core output caching fixes this with a single attribute or one line of middleware configuration: the server stores the fully rendered response and hands it back on the next request without touching your controller, your services, or your database. In this tutorial you'll learn how output caching works in ASP.NET Core 7, 8, 9 and 10, how it differs from response caching, how to configure cache policies, how to vary the cache by query string or header, how to invalidate cached entries with tags, and how to back the cache with Redis for multi-server deployments. Every example is runnable C#.

What Is ASP.NET Core Output Caching?

Output caching is server-side middleware that stores HTTP responses and replays them for matching requests. The first request for GET /api/products runs through the full pipeline. The output cache middleware captures the status code, headers, and body, stores them, and on the next matching request returns that stored response immediately. Your endpoint code never executes again until the entry expires or is evicted.

It was introduced in .NET 7 as a replacement for the older response caching middleware, and it's the recommended approach for any modern ASP.NET Core project. The key difference is who is in control:

  • Response caching relies on standard HTTP cache headers (Cache-Control, Vary). The client can bypass it by sending Cache-Control: no-cache, and the server-side store is fairly rigid.
  • Output caching is configured entirely on the server. Clients cannot bypass it, you can evict entries programmatically, you can cache based on any request property, and you can plug in Redis or another distributed store.

Output caching also solves the cache stampede problem: when an entry expires and 500 requests arrive at once, only one executes the endpoint while the others wait for that result. Response caching has no such protection.

Getting Started: Enable ASP.NET Core Output Caching in 3 Steps

No NuGet package is needed; output caching ships in the shared framework. Register the services, add the middleware, and mark what to cache.

var builder = WebApplication.CreateBuilder(args);

// 1. Register output caching services
builder.Services.AddOutputCache();

var app = builder.Build();

// 2. Add the middleware. Place it AFTER UseCors and UseRouting,
//    but BEFORE the endpoints that should be cached.
app.UseCors();
app.UseOutputCache();

// 3. Opt an endpoint into caching
app.MapGet("/api/time", () => new { Time = DateTime.UtcNow })
   .CacheOutput();

app.Run();

Hit /api/time twice and you'll see the same timestamp. By default the entry lives for 60 seconds. Middleware ordering matters: if you put UseOutputCache() before UseCors(), cached responses will be missing CORS headers and browser calls will fail intermittently, a classic hard-to-debug bug.

Using the [OutputCache] Attribute in Controllers

For MVC and Web API controllers, use the [OutputCache] attribute. It works at the action or controller level.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repo;

    public ProductsController(IProductRepository repo) => _repo = repo;

    // Cache for 5 minutes
    [HttpGet]
    [OutputCache(Duration = 300)]
    public async Task<IActionResult> GetAll()
    {
        var products = await _repo.GetAllAsync();
        return Ok(products);
    }

    // Route values are part of the cache key automatically,
    // so /api/products/1 and /api/products/2 are cached separately.
    [HttpGet("{id:int}")]
    [OutputCache(Duration = 300)]
    public async Task<IActionResult> GetById(int id)
    {
        var product = await _repo.GetByIdAsync(id);
        return product is null ? NotFound() : Ok(product);
    }
}

One important detail: by default, only 200 OK responses to GET and HEAD requests are cached. A 404 is not stored, and authenticated requests (those with an Authorization header or cookies) are skipped entirely. That last rule exists to prevent leaking one user's data to another, and you should think hard before overriding it.

Defining Reusable Cache Policies

Scattering Duration = 300 across 40 actions is a maintenance headache. Define named policies once in Program.cs and reference them by name.

builder.Services.AddOutputCache(options =>
{
    // Applies to every endpoint that opts in with no policy name
    options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(30)));

    // Long-lived reference data
    options.AddPolicy("Catalog", policy =>
        policy.Expire(TimeSpan.FromMinutes(15))
              .Tag("catalog"));

    // Search results: vary by the query string, short TTL
    options.AddPolicy("Search", policy =>
        policy.Expire(TimeSpan.FromSeconds(45))
              .SetVaryByQuery("q", "page", "pageSize"));

    // Explicitly disable caching for volatile endpoints
    options.AddPolicy("NoCache", policy => policy.NoCache());
});

Then apply them:

// Minimal API
app.MapGet("/api/catalog", GetCatalog).CacheOutput("Catalog");
app.MapGet("/api/search", Search).CacheOutput("Search");

// Controller
[HttpGet]
[OutputCache(PolicyName = "Catalog")]
public async Task<IActionResult> GetCatalog() { ... }

A subtle but critical point about SetVaryByQuery: by default, output caching varies by the entire query string. That's safe but wasteful; ?q=laptop&utm_source=twitter and ?q=laptop become two entries. Naming the query keys you actually care about dramatically improves your hit ratio.

Varying by Header, Route, or Custom Values

Sometimes the same URL legitimately produces different output. A localized API varies by Accept-Language; a multi-tenant API varies by a tenant ID resolved from the host name.

options.AddPolicy("Localized", policy =>
    policy.Expire(TimeSpan.FromMinutes(10))
          .SetVaryByHeader("Accept-Language"));

options.AddPolicy("PerTenant", policy =>
    policy.Expire(TimeSpan.FromMinutes(5))
          .VaryByValue(context =>
          {
              var tenant = context.Request.Host.Host.Split('.')[0];
              return new KeyValuePair<string, string>("tenant", tenant);
          }));

If you forget to vary by something that affects the output, users will receive the wrong response. That's the single most common output caching bug, so always ask: "What inputs, besides the path, change this response?"

Invalidating the Cache with Tags

Time-based expiration is fine for data that changes on a schedule, but when an admin updates a product you don't want stale JSON for 15 more minutes. Tags let you evict groups of entries on demand through IOutputCacheStore.

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repo;
    private readonly IOutputCacheStore _cache;

    public ProductsController(IProductRepository repo, IOutputCacheStore cache)
    {
        _repo = repo;
        _cache = cache;
    }

    [HttpGet]
    [OutputCache(PolicyName = "Catalog")] // tagged "catalog"
    public async Task<IActionResult> GetAll() => Ok(await _repo.GetAllAsync());

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, ProductDto dto, CancellationToken ct)
    {
        await _repo.UpdateAsync(id, dto, ct);

        // Evict every cached response tagged "catalog"
        await _cache.EvictByTagAsync("catalog", ct);

        return NoContent();
    }
}

Tags can also be attached per endpoint with .Tag("products", "catalog") on minimal APIs, or with [OutputCache(Tags = new[] { "products" })] on actions. A good convention is one broad tag per resource type plus, where helpful, a specific tag per entity (for example product:42) so you can evict as little or as much as needed.

Scaling Out: Redis Output Cache for Multiple Servers

The default store is in-memory, which means every server behind your load balancer keeps its own cache and evictions on one node don't reach the others. For anything running on more than one instance (Azure App Service with scale-out, Kubernetes, AWS ECS), use the Redis-backed store, available since .NET 8.

// dotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "MyApi:";
});

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Catalog", p => p.Expire(TimeSpan.FromMinutes(15)).Tag("catalog"));
});

Nothing else in your code changes: policies, attributes, and EvictByTagAsync all work identically, and now an eviction from one server clears the entry for all of them. Keep an eye on MaximumBodySize (default 64 MB) and SizeLimit (default 100 MB for the memory store); responses larger than the limit are simply not cached, silently.

Caching Authenticated Responses (Carefully)

Since authenticated requests are skipped by default, developers sometimes reach for a custom policy to force them through. If you genuinely need this, vary the cache by user identity so entries never cross accounts.

public sealed class PerUserCachePolicy : IOutputCachePolicy
{
    public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken ct)
    {
        var userId = context.HttpContext.User.FindFirst("sub")?.Value;

        // Only cache when we can identify the user
        context.EnableOutputCaching = userId is not null;
        context.AllowCacheLookup = userId is not null;
        context.AllowCacheStorage = userId is not null;
        context.AllowLocking = true;
        context.ResponseExpirationTimeSpan = TimeSpan.FromSeconds(30);

        context.CacheVaryByRules.VaryByValues["user"] = userId ?? string.Empty;
        return ValueTask.CompletedTask;
    }

    public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken ct)
        => ValueTask.CompletedTask;

    public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken ct)
    {
        var response = context.HttpContext.Response;
        if (response.StatusCode != StatusCodes.Status200OK)
        {
            context.AllowCacheStorage = false;
        }
        return ValueTask.CompletedTask;
    }
}

// Register:
options.AddPolicy("PerUser", new PerUserCachePolicy());

Use this sparingly. Per-user caching multiplies your cache size by the number of active users, and a mistake here is a data breach, not a bug.

ASP.NET Core Output Caching Best Practices

  • Cache reads, never writes. POST, PUT, PATCH, and DELETE are never cached by default; don't fight that.
  • Prefer short TTLs plus tag eviction over long TTLs. A 60-second cache still absorbs 99% of load on a hot endpoint while limiting staleness.
  • Name your query keys with SetVaryByQuery so tracking parameters don't fragment the cache.
  • Put UseOutputCache() after UseCors() and after any middleware whose headers must be included in the cached response.
  • Use Redis in production when you run more than one instance, and use InstanceName to namespace keys per application.
  • Measure it. Log the Age response header (the middleware adds it) or add an X-Cache header in a custom policy to confirm hits and misses during load tests.

Common Pitfalls

  • Forgetting to vary by an input. Localized, tenant-specific, or header-dependent responses served to the wrong caller.
  • Expecting caching in the browser. Output caching is server-side only. If you also want CDN or browser caching, set Cache-Control headers yourself.
  • Caching error responses. Only 200 is cached by default; if you write a custom policy, replicate that check as shown above.
  • Response too large. Bodies over MaximumBodySize are silently skipped. If a heavy endpoint never seems to hit the cache, check this first.
  • Mixing up [ResponseCache] and [OutputCache]. They're different systems. [ResponseCache] only sets headers unless you also add response caching middleware.

Conclusion: Key Takeaways

ASP.NET Core output caching is the fastest performance win available to most APIs: one attribute or one .CacheOutput() call can turn a 200 ms database-backed endpoint into a sub-millisecond response and cut database load by orders of magnitude. Remember these points:

  • Enable it with AddOutputCache() and UseOutputCache(), then opt in per endpoint with [OutputCache] or .CacheOutput().
  • Define named policies for consistent TTLs, vary-by rules, and tags.
  • Use SetVaryByQuery, SetVaryByHeader, and VaryByValue so every input that changes output is part of the cache key.
  • Evict on demand with IOutputCacheStore.EvictByTagAsync instead of relying on long expirations.
  • Switch to the Redis store for multi-instance deployments; your code stays the same.

Start with one hot, read-heavy endpoint, add a 60-second policy, and watch the response times in your monitoring dashboard drop. Once you've seen the impact, expand caching thoughtfully across your API.

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...