Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 034 / 100

Output caching in ASP.NET Core: best practices explained

Cache whole responses on the server — with tags you can invalidate.

Expona Admin5 min read

Output caching in ASP.NET Core

If you've ever watched a database groan under repeated requests for the same data, you know the pain of inefficient caching strategies. Output caching in ASP.NET Core lets you cache entire HTTP responses on the server with fine-grained control — no client cooperation needed, no cache headers to negotiate. In this post, I'll walk you through setting it up, invalidating it intelligently, and scaling it across multiple instances.

Response caching vs output caching

Before we dig into output caching, let's clarify the distinction from response caching. Response caching relies on HTTP headers — Cache-Control, ETag, Last-Modified — that you send to clients and intermediaries. The browser or a reverse proxy decides whether to serve a cached copy. It's powerful for public content, but it's inherently unpredictable: a client can ignore your headers, a proxy might evict the cache, or a user might force a refresh.

Output caching is different. The server stores the entire response in memory (or Redis) and serves it to all clients until you decide to invalidate it. There's no negotiation, no client headers, no surprises. You own the cache lifecycle completely.

In production, I've found output caching invaluable for endpoints that serve read-heavy data with infrequent updates — product catalogs, configuration endpoints, leaderboards, or admin dashboards. Response caching is better for truly public content where you want browsers to cache it; output caching is better for backend APIs where you want server-side control.

Cache an endpoint

Setting up output caching in ASP.NET Core 8+ is straightforward. First, register the service in Program.cs:

csharp
builder.Services.AddOutputCache();

Then add the middleware to your pipeline, before authentication and routing:

csharp
app.UseOutputCache();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/products", GetProducts)
  .CacheOutput(p => p
    .Expire(TimeSpan.FromMinutes(5))
    .Tag("products"));

app.Run();

The CacheOutput() extension method wraps your endpoint with a cache policy. Let me break down what's happening:

  • Expire() — The response stays cached for 5 minutes. After that, the next request triggers a fresh call to GetProducts.
  • Tag() — You label this cached response with a tag. Later, you can invalidate all responses tagged "products" in one operation.

Here's a more complete example with a handler:

csharp
public record Product(int Id, string Name, decimal Price);

async Task<IResult> GetProducts(IProductRepository repo)
{
  var products = await repo.ListAsync();
  return Results.Ok(products);
}

app.MapGet("/products", GetProducts)
  .CacheOutput(p => p
    .Expire(TimeSpan.FromMinutes(5))
    .Tag("products")
    .VaryByQuery("category")
    .WithName("GetProducts"));

app.Run();

Notice the VaryByQuery("category"). This means the cache respects query parameters — a request to /products?category=electronics gets cached separately from /products?category=clothing. You can also vary by headers, claim values, or custom logic:

csharp
.CacheOutput(p => p
  .Expire(TimeSpan.FromMinutes(5))
  .Tag("products")
  .VaryByQuery("category", "sort")
  .VaryByHeader("Accept-Language")
  .VaryByValue(ctx => ctx.User.FindFirst("tenant-id")?.Value ?? "default"))

In a multi-tenant SaaS, that last line is gold. Each tenant gets a separate cached response based on their tenant ID claim.

Invalidate by tag

Caching is only useful if you can invalidate it when data changes. That's where tags come in. When a product is updated, you inject IOutputCacheStore and call:

csharp
[HttpPost("/products/{id}")]
public async Task<IResult> UpdateProduct(int id, UpdateProductRequest req, IProductRepository repo, IOutputCacheStore cache)
{
  await repo.UpdateAsync(id, req);
  await cache.EvictByTagAsync("products", default);
  return Results.Ok();
}

[HttpPost("/products")]
public async Task<IResult> CreateProduct(CreateProductRequest req, IProductRepository repo, IOutputCacheStore cache)
{
  var product = await repo.CreateAsync(req);
  await cache.EvictByTagAsync("products", default);
  return Results.Created($"/products/{product.Id}", product);
}

When you call EvictByTagAsync("products", ...), all cached responses tagged "products" are immediately removed. The next client request hits the database and builds a fresh response, which gets cached again.

This is infinitely better than a blind time-based expiration. You get instant consistency without sacrificing performance. In a busy e-commerce platform, this approach means product updates are visible everywhere within milliseconds, not minutes.

You can also invalidate multiple tags in a batch:

csharp
await cache.EvictByTagAsync("products", default);
await cache.EvictByTagAsync("categories", default);
await cache.EvictByTagAsync("promotions", default);

Use Redis for multiple instances

The default IOutputCacheStore is in-memory. That works fine for a single server, but in production you're usually running multiple instances behind a load balancer. If instance A caches a response and instance B gets invalidated, clients hitting instance A still see stale data.

Redis solves this. All instances share the same cache, so invalidation is truly global:

csharp
builder.Services.AddOutputCache();
builder.Services.AddStackExchangeRedisCache(options =>
{
  options.Configuration = builder.Configuration.GetConnectionString("Redis");
});

// Replace the default output cache store with Redis
builder.Services.AddSingleton<IOutputCacheStore>(sp =>
  new StackExchangeRedisOutputCacheStore(
    sp.GetRequiredService<IDistributedCache>(),
    sp.GetRequiredService<IOptions<OutputCacheOptions>>()
  )
);

Now all three instances write to and read from the same Redis instance. When one instance invalidates a tag, all instances see the change immediately.

In my experience, even a modest Redis cluster (3 nodes) can handle caching for a fleet of 20+ API servers. The throughput is excellent, and the consistency guarantees are rock solid. Just make sure to monitor Redis memory and set appropriate eviction policies (allkeys-lru is my go-to).

Common mistakes

  • Forgetting to vary by sensitive data. If you cache a user-specific endpoint without varying by user ID, every client sees the first user's data. Use VaryByValue() to include claims, tenant IDs, or API keys.
  • Over-aggressive expiration. Setting a 24-hour cache on data that changes hourly is worse than no cache. Start conservative (5–10 minutes) and monitor cache hit rates in production.
  • Not invalidating on all mutation paths. If you update a product via a direct database script or a background job, the cache won't know. Either route all mutations through your API or set shorter expiry times.
  • Caching on POST, PUT, or DELETE. Only cache GET requests (and the occasional HEAD). Accidental caching of mutations is a data consistency nightmare.
  • Mixing in-memory and Redis without a plan. If you have some instances with in-memory cache and others with Redis, invalidation becomes unreliable. Standardize on one approach per environment.

Key takeaways

  • Output caching is server-controlled. Unlike response caching (which depends on client headers), it's deterministic and predictable.
  • Use tags for surgical invalidation. Tag related endpoints and invalidate them together when data changes, ensuring freshness without full cache clears.
  • Vary by query, header, and claims. Multi-variant caching lets you serve different responses based on context — essential for multi-tenant and user-specific endpoints.
  • Go Redis in production. In-memory is fine for development. For multiple instances, Redis ensures cache consistency across your fleet.
  • Measure and monitor. Track cache hit rates and adjust expiry times based on real access patterns, not guesses.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles