ASP.NET Core Caching: Output Cache, Redis & Invalidation Strategies That Actually Work
32 min read
Advanced
Caching is one of the highest-leverage performance tools available to an ASP.NET Core developer — and also one of the most reliably misused. The typical first attempt either caches too aggressively (returning stale product data after an update) or not at all (every request hits the database for data unchanged for hours). Both outcomes are avoidable with the right mental model.
This tutorial builds a Catalog API with caching at every layer: Output Cache for GET endpoints, Redis as the shared backing store for multi-instance deployments, and invalidation rules that stay correct when data changes — without nuking the entire cache on every write. You will finish with a caching strategy applicable to any production API.
What You'll Build
A caching-focused Catalog API (tutorials/aspnet-core/CachingCatalogApi/) covering the full production caching stack:
Output Cache middleware — named policies with TTLs, vary-by-route, vary-by-query, and vary-by-header rules applied to list and detail endpoints
Redis as the Output Cache backing store — shared cache for multi-instance deployments where in-memory caching produces stale inconsistencies
Tag-based invalidation — evict exactly the affected entries when a product changes, without touching unrelated cached responses
Key-pattern and version-token invalidation — for IDistributedCache scenarios where you manage keys directly
Event-driven invalidation — a channel that broadcasts "product changed" so mutations evict the right entries regardless of which code path triggered them
Write-path discipline — POST, PUT, and DELETE endpoints that evict precisely, not broadly
Stampede protection — Output Cache request coalescing plus jittered TTLs for IDistributedCache
Cache observability — hit/miss metrics via a metered IOutputCacheStore wrapper and Cache-Status response headers for debugging without a profiler
Project Setup & Catalog Domain
A minimal Catalog API — products with categories. Simple enough that the caching logic stays in focus, realistic enough that the invalidation rules cover patterns you actually encounter in production.
Terminal — Scaffold & Add Packages
dotnet new webapi -n CachingCatalogApi --use-minimal-apis
cd CachingCatalogApi
dotnet run
# Output Cache Redis backing store
dotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis
# IDistributedCache Redis implementation
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
# EF Core + SQLite for the data layer (not the focus here)
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design
# Metrics
dotnet add package System.Diagnostics.DiagnosticSource
Domain Models
Models/Category.cs & Models/Product.cs
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public ICollection<Product> Products { get; set; } = [];
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public decimal Price { get; set; }
public bool IsActive { get; set; } = true;
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
// Changes on every update — useful as a vary key and for ETag generation
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
// DTOs — cache the response shape, never the domain entity
public record ProductSummary(int Id, string Name, decimal Price, int CategoryId);
public record ProductDetail(int Id, string Name, string Description,
decimal Price, bool IsActive, int CategoryId,
string CategoryName, DateTimeOffset UpdatedAt);
Cache the DTO, Not the Domain Entity
Always cache the serialized DTO that your API returns — never a domain entity with navigation properties. Cached entities can retain EF Core change-tracker references, cause lazy-load exceptions when deserialized, and include data that should not be in the response. With Output Cache this is automatic — it caches the HTTP response body, which is already the serialized DTO. With IDistributedCache, serialize the DTO explicitly before storing.
What to Cache and What to Leave Alone
The decision of what to cache is more important than any implementation detail. Cache the wrong things and you serve stale data; cache too little and you get no benefit; cache user-specific data under shared keys and you cause a security incident.
Caching Decision Framework
GOOD CACHING CANDIDATES
GET /products — list, changes infrequently, same for all users
GET /products/{id} — single product detail, stable between edits
GET /categories — reference data, changes very rarely
GET /products?category=3 — filtered list, stable until category data changes
CACHE WITH CARE — vary rules required
GET /products?page=2&sort=price — vary by all query params, or cache serves wrong data
GET /search?q=laptop — vary by query, watch for key cardinality explosion
GET /products (Accept: text/csv) — vary by Accept header if multiple formats served
DO NOT CACHE
POST /products — mutating — never cache write operations
PUT /products/{id} — mutating
DELETE /products/{id} — mutating
GET /cart — user-specific, personalised
GET /orders — user-specific, security-sensitive
Any endpoint returning Set-Cookie — Output Cache skips these by default
Any response with Cache-Control: no-store from downstream — honour it
Vary Rules Are Correctness Requirements, Not Optimisation
A vary rule defines the set of inputs that produce a unique response. If GET /products accepts a ?sort=price parameter but you cache without varying by query string, the first request's sort order is served to every subsequent request regardless of their sort parameter. Output Cache varies by route values by default — but not by query string. You must explicitly add .SetVaryByQuery("sort", "page", "category") for every parameter that produces a different response. Omitting a vary dimension is a correctness bug, not a performance choice.
Measure Before You Cache
Cache only what you can prove needs it. Add OpenTelemetry tracing to your data access layer and identify the slowest or most-called database queries — those are your caching candidates. Caching an endpoint that responds in 5ms from an indexed query provides negligible benefit but adds invalidation complexity you will maintain forever. Focus caching effort on endpoints with response times over 50ms or call volumes over 100 requests per minute.
Output Cache: Policies, TTLs & Vary Rules
Output Cache middleware, introduced in .NET 7 and refined in .NET 8, stores complete HTTP responses and short-circuits the middleware pipeline on a hit. It improves on the older Response Caching middleware: configurable per-endpoint, supports tag-based invalidation, and works with Redis as a shared backing store.
Program.cs — Output Cache Registration with Named Policies
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(options =>
{
// Base policy: no caching by default — endpoints opt in explicitly
options.AddBasePolicy(policy => policy.NoCache());
// Products list: 5-minute TTL, vary by all relevant query params
options.AddPolicy("ProductList", policy =>
policy
.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("category", "sort", "page", "pageSize", "search")
.Tag("products")
.SetCacheKeyPrefix("catalog:products:list:"));
// Product detail: 10-minute TTL, vary by route id only
options.AddPolicy("ProductDetail", policy =>
policy
.Expire(TimeSpan.FromMinutes(10))
.SetVaryByRouteValue("id")
.Tag("products")
.Tag("product-{id}") // per-product tag for surgical eviction
.SetCacheKeyPrefix("catalog:products:detail:"));
// Categories: 30-minute TTL — changes very rarely
options.AddPolicy("CategoryList", policy =>
policy
.Expire(TimeSpan.FromMinutes(30))
.Tag("categories")
.SetCacheKeyPrefix("catalog:categories:"));
});
Program.cs — Correct Middleware Pipeline Order
var app = builder.Build();
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
// Output Cache: AFTER auth middleware (so it can see identity for vary rules)
// BEFORE endpoint execution (so it can short-circuit before handlers run)
app.UseOutputCache();
app.MapProductEndpoints();
app.MapCategoryEndpoints();
app.Run();
Output Cache vs Response Caching Middleware
ASP.NET Core ships with two caching middleware options: the older UseResponseCaching() and the newer UseOutputCache(). Use Output Cache for all new projects. Response Caching respects HTTP cache-control headers from the client — a client sending Cache-Control: no-cache can bypass it entirely. Output Cache is controlled server-side via policies, ignores client hints, and supports tag-based invalidation and Redis backing that Response Caching does not have.
Caching the List & Detail Endpoints
Applying Output Cache policies to Minimal API endpoints and verifying that vary rules behave correctly before layering on Redis and invalidation.
Endpoints/ProductEndpoints.cs — List & Detail with Cache Policies
public static class ProductEndpoints
{
public static void MapProductEndpoints(this WebApplication app)
{
var group = app.MapGroup("/products").WithTags("Products");
// List — vary rules applied by the "ProductList" policy
group.MapGet("/", async (
[FromQuery] int? category,
[FromQuery] string? sort,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CatalogDbContext db = null!) =>
{
var query = db.Products.Where(p => p.IsActive).AsQueryable();
if (category.HasValue)
query = query.Where(p => p.CategoryId == category.Value);
query = sort switch
{
"price" => query.OrderBy(p => p.Price),
"price_desc" => query.OrderByDescending(p => p.Price),
"name" => query.OrderBy(p => p.Name),
_ => query.OrderBy(p => p.Id)
};
var items = await query
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(p => new ProductSummary(p.Id, p.Name, p.Price, p.CategoryId))
.ToListAsync();
return Results.Ok(items);
})
.CacheOutput("ProductList"); // apply the named policy
// Detail — varies by route {id}, tags with "product-{id}"
group.MapGet("/{id:int}", async (int id, CatalogDbContext db) =>
{
var product = await db.Products
.Include(p => p.Category)
.Where(p => p.Id == id && p.IsActive)
.Select(p => new ProductDetail(
p.Id, p.Name, p.Description, p.Price,
p.IsActive, p.CategoryId, p.Category.Name, p.UpdatedAt))
.FirstOrDefaultAsync();
return product is null ? Results.NotFound() : Results.Ok(product);
})
.CacheOutput("ProductDetail");
// Write endpoints — covered in Section 9
group.MapPost("/", CreateProduct);
group.MapPut("/{id:int}", UpdateProduct);
group.MapDelete("/{id:int}", DeleteProduct);
}
}
Verify Vary Rules with curl Before Adding Redis
Before building the invalidation layer, confirm your vary rules work correctly. Make two requests: GET /products?sort=price and GET /products?sort=name. Inspect the Age response header — the second request should have Age: 0 (a fresh origin hit, not a cached response from the first). If both return the same Age value, your vary rules are not capturing the sort parameter and you are serving the wrong sort order to users. Fix vary configuration before adding Redis — a cache that varies incorrectly is worse than no cache.
Redis: Distributed Cache for Multi-Instance APIs
In-memory Output Cache works correctly on a single instance. The moment you deploy two or more instances behind a load balancer, the cache becomes inconsistent — fills on one instance are invisible to others, and tag evictions only affect the instance that processes the write request.
The Multi-Instance Stale Data Problem
# Three-instance deployment, load balancer round-robins requests
Request 1 → Instance A → MISS → DB query → cache FILL on A only
Request 2 → Instance B → MISS → DB query → cache FILL on B only
Request 3 → Instance C → MISS → DB query → cache FILL on C only
# Product 42 is updated — PUT /products/42 routed to Instance B
# Tag eviction fires on Instance B only — A and C still hold the stale entry
Request 4 → Instance A → cache HIT → STALE data served ← BUG
Request 5 → Instance B → cache MISS → FRESH data served (evicted)
Request 6 → Instance C → cache HIT → STALE data served ← BUG
# With Redis as the backing store:
# All instances share one Redis store
# Fills are immediately visible to every instance
# Tag eviction on Instance B removes entries from Redis — all instances see it
Program.cs — Redis Output Cache Store + IDistributedCache
var redisConn = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379";
// Redis as the Output Cache backing store — replaces the default in-memory store
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration = redisConn;
options.InstanceName = "CatalogApi:OutputCache:"; // key namespace prefix
});
// Redis IDistributedCache — used for manual key/version patterns in Sections 7-8
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisConn;
options.InstanceName = "CatalogApi:DistCache:";
});
Set InstanceName to a descriptive prefix such as "CatalogApi:OutputCache:". Without it, all applications sharing a Redis instance write keys directly into the root namespace — a key named products:list from your Catalog API can collide with the same key from a completely different service. Use a prefix that includes the application name and environment: "CatalogApi:prod:OutputCache:" versus "CatalogApi:staging:OutputCache:", and set it consistently across all services that share the Redis instance.
Tag-Based Invalidation
Output Cache tag-based invalidation is the most precise eviction tool available: tag each cached response at registration time, then evict by tag when the relevant data changes. No knowledge of specific cache keys required — the framework finds and removes all entries sharing the tag.
Tag Strategy — Plan Before You Code
# Tag taxonomy for the Catalog API
#
# Tag "products"
# Applied to: ProductList policy, ProductDetail policy
# Evict when: ANY product changes, is created, or deleted
# Effect: clears all list pages and all detail entries
#
# Tag "product-{id}" (e.g. "product-42")
# Applied to: ProductDetail policy, per product
# Evict when: the specific product with that ID changes
# Effect: clears only the detail for product 42 — lists stay warm
#
# Tag "categories"
# Applied to: CategoryList policy
# Evict when: any category is created, renamed, or deleted
#
# Design rule: tag granularity should match your invalidation granularity.
# If you always evict all products together, one "products" tag is sufficient.
# If you need per-product precision, add "product-{id}" alongside "products".
Evicting by Tag After a Product Update
public static async Task<IResult> UpdateProduct(
int id,
UpdateProductRequest request,
CatalogDbContext db,
IOutputCacheStore cacheStore,
CancellationToken ct)
{
var product = await db.Products.FindAsync([id], ct);
if (product is null) return Results.NotFound();
product.Name = request.Name;
product.Price = request.Price;
product.Description = request.Description;
product.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
// Surgical eviction: only this product's detail cache entry
await cacheStore.EvictByTagAsync($"product-{id}", ct);
// Also evict product lists — they contain this product's summary
await cacheStore.EvictByTagAsync("products", ct);
return Results.NoContent();
}
EvictByTagAsync Is Atomic Across All Redis Keys
When Redis is the Output Cache backing store, EvictByTagAsync uses a Redis Set to track which keys belong to each tag. Evicting a tag deletes all keys in that set atomically and consistently across all instances — this is one of the strongest reasons to use Output Cache with Redis over manually managed IDistributedCache keys. The tag set is maintained automatically by the framework each time a cache entry is stored; you never need to manage it yourself.
Key-Pattern Invalidation & Version Tokens
For IDistributedCache scenarios — caching computed results, aggregates, or cross-service data — you manage cache keys directly. Two patterns keep these keys invalidatable without expensive scan-and-delete operations across Redis.
CacheKeys.cs — Centralised Key Namespace
// Centralise all cache key construction — prevents typos, aids invalidation auditing
public static class CacheKeys
{
public static string ProductDetail(int id) => $"catalog:product:{id}:detail";
public static string ProductsByCategory(int id) => $"catalog:category:{id}:products";
public static string CategoryAll() => "catalog:category:all";
public static string CategoryDetail(int id) => $"catalog:category:{id}:detail";
public static string ProductVersion() => "catalog:version:products";
public static string CategoryVersion() => "catalog:version:categories";
}
Version Token — Invalidate a Namespace Without Deleting Keys
public class VersionedCacheService(IDistributedCache cache)
{
// Read the current version for a namespace
public async Task<long> GetVersionAsync(string versionKey, CancellationToken ct)
{
var bytes = await cache.GetAsync(versionKey, ct);
return bytes is null ? 0 : BitConverter.ToInt64(bytes);
}
// Build a versioned cache key — embeds the version number
// "catalog:product:42:detail:v7" is only valid when version is 7
public async Task<string> BuildVersionedKeyAsync(
string baseKey, string versionKey, CancellationToken ct)
{
var version = await GetVersionAsync(versionKey, ct);
return $"{baseKey}:v{version}";
}
// Invalidate an entire namespace by incrementing the version counter
// Old keys with the previous version number are never requested again
// They expire naturally via their TTL — no explicit bulk delete needed
public async Task InvalidateNamespaceAsync(string versionKey, CancellationToken ct)
{
var current = await GetVersionAsync(versionKey, ct);
var newVersion = BitConverter.GetBytes(current + 1);
await cache.SetAsync(versionKey, newVersion,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(30)
}, ct);
}
}
// On product update: increment the product namespace version
// All keys of the form "catalog:product:*:v{old}" are now effectively stale
await versionedCache.InvalidateNamespaceAsync(CacheKeys.ProductVersion(), ct);
Version Tokens Trade Memory for Simplicity
Version tokens leave stale keys in Redis until their TTL expires — they are not deleted, just ignored because no new request generates the old version key anymore. Redis memory usage is slightly higher than with explicit deletion, but the tradeoff is worth it: you replace O(n) individual key deletions with a single O(1) atomic version increment. Set TTLs to match the maximum time you can accept stale data — the version token pattern relies on natural expiry to clean up orphaned keys.
Event-Driven Invalidation: Broadcasting Data Changes
When the mutation and the cache live in the same process, direct eviction after SaveChanges is straightforward. When mutations can originate in a background worker, a bulk import, or a separate service, you need the cache layer to react to domain events rather than being called directly by every code path that modifies data.
public class OutputCacheInvalidator(IOutputCacheStore store) : ICacheInvalidator
{
public async Task InvalidateProductAsync(int productId, CancellationToken ct = default)
{
await store.EvictByTagAsync($"product-{productId}", ct);
await store.EvictByTagAsync("products", ct);
}
public Task InvalidateAllProductsAsync(CancellationToken ct = default) =>
store.EvictByTagAsync("products", ct);
public Task InvalidateCategoriesAsync(CancellationToken ct = default) =>
store.EvictByTagAsync("categories", ct);
}
// Background service — listens on an in-process channel
// For cross-service events, replace the channel with Redis pub/sub or a message bus
public class CacheInvalidationWorker(
ChannelReader<ProductChangedEvent> events,
ICacheInvalidator invalidator,
ILogger<CacheInvalidationWorker> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var evt in events.ReadAllAsync(ct))
{
try
{
switch (evt.ChangeType)
{
case ProductChangeType.PriceChanged:
case ProductChangeType.Updated:
await invalidator.InvalidateProductAsync(evt.ProductId, ct);
break;
case ProductChangeType.Created:
case ProductChangeType.Deleted:
await invalidator.InvalidateAllProductsAsync(ct);
break;
}
logger.LogDebug("Cache invalidated: product {Id} ({Type})", evt.ProductId, evt.ChangeType);
}
catch (Exception ex)
{
// Log but do NOT rethrow — invalidation failure must not crash the worker
// The cache entry expires naturally at TTL; this is an acceptable degradation
logger.LogError(ex, "Cache invalidation failed for product {Id}", evt.ProductId);
}
}
}
}
A Failed Cache Invalidation Is Not a Fatal Error
Cache invalidation failures — Redis temporarily unreachable, a timeout, a network hiccup — should be logged and swallowed, never propagated as exceptions that roll back the originating write. The write already succeeded in the database. If invalidation fails, the stale cache entry expires at its TTL and the next request after expiry gets a fresh value. Stale cache for a bounded window is a far better outcome than a failed write operation that misleads the caller about whether their data was saved.
Write-Path Discipline: POST, PUT & DELETE
Write endpoints have one job with respect to caching: evict exactly what changed, and nothing more. Over-invalidation — evicting everything on every write — defeats the purpose of caching. Under-invalidation — missing entries that reference the changed data — serves stale responses.
ProductEndpoints.cs — Write Paths with Precise Invalidation
// POST /products — new product appears in list pages; no detail entry yet
private static async Task<IResult> CreateProduct(
CreateProductRequest req, CatalogDbContext db,
ICacheInvalidator cache, CancellationToken ct)
{
var product = new Product
{
Name = req.Name, Price = req.Price,
CategoryId = req.CategoryId, UpdatedAt = DateTimeOffset.UtcNow
};
db.Products.Add(product);
await db.SaveChangesAsync(ct);
// New product affects ALL list pages (could appear on any page/sort combination)
// Does NOT affect any existing product's detail entry
await cache.InvalidateAllProductsAsync(ct);
return Results.Created($"/products/{product.Id}", product);
}
// PUT /products/{id} — existing product changes
private static async Task<IResult> UpdateProduct(
int id, UpdateProductRequest req,
CatalogDbContext db, ICacheInvalidator cache, CancellationToken ct)
{
var product = await db.Products.FindAsync([id], ct);
if (product is null) return Results.NotFound();
var categoryChanged = product.CategoryId != req.CategoryId;
product.Name = req.Name; product.Price = req.Price;
product.Description = req.Description; product.CategoryId = req.CategoryId;
product.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
// Always evict this product's detail
await cache.InvalidateProductAsync(id, ct);
// Category change also affects category-filtered list pages
if (categoryChanged) await cache.InvalidateAllProductsAsync(ct);
return Results.NoContent();
}
// DELETE /products/{id} — product no longer exists in any list or detail
private static async Task<IResult> DeleteProduct(
int id, CatalogDbContext db,
ICacheInvalidator cache, CancellationToken ct)
{
var product = await db.Products.FindAsync([id], ct);
if (product is null) return Results.NotFound();
db.Products.Remove(product);
await db.SaveChangesAsync(ct);
// Evict this product's detail entry (tag: product-{id})
// AND all product list pages (tag: products) — deleted product must not appear in any list
await cache.InvalidateProductAsync(id, ct); // evicts product-{id} + products tag
await cache.InvalidateAllProductsAsync(ct); // belt-and-suspenders: explicitly evict lists
return Results.NoContent();
}
Document Each Write Endpoint's Cache Impact Explicitly
For every write endpoint, add a comment stating exactly which cache entries it invalidates and why — for example: Invalidates: product-{id} detail, all product lists. Does NOT invalidate: other products' details, category list. This comment forces you to reason through invalidation correctness at the time of writing, not after a user reports stale data. Cache correctness bugs are almost always silent and only discovered in production. The discipline of explicit invalidation documentation catches them at code review.
Stampede Protection: Coalescing & Jittered TTLs
A cache stampede occurs when a popular cached value expires and many concurrent requests all find a cache miss simultaneously — all firing the expensive origin query at once. For a product list endpoint serving 200 requests per second, a TTL expiry can produce hundreds of simultaneous database queries in a narrow window.
Output Cache Built-In Request Coalescing
// Output Cache coalesces concurrent requests with the same cache key automatically.
// When 200 requests arrive simultaneously for GET /products?sort=price:
// Request 1 → cache MISS → proceeds to the handler
// Requests 2-200 → find a "pending" state → wait for Request 1 to complete
// When Request 1 stores the response → all waiting requests receive it immediately
// Result: 1 database query instead of 200
// No code changes needed — coalescing is the default Output Cache behaviour.
// Disable coalescing only if you have strict per-request isolation requirements
// (rarely needed and not recommended for high-traffic endpoints)
options.AddPolicy("NoCoalesce", policy =>
policy
.Expire(TimeSpan.FromMinutes(5))
.AllowLocking(false)); // disables coalescing
// Coalescing is per cache key — high-cardinality endpoints like GET /products/{id}
// have independent locks per product ID, so no contention between different products.
public static class CacheExtensions
{
private static readonly Random _rng = Random.Shared;
// Add ±jitterPercent randomisation to the base TTL
// Base: 10 minutes, jitter: 20% → actual TTL: 8–12 minutes
// Prevents thousands of simultaneously cached entries from expiring together
public static TimeSpan WithJitter(this TimeSpan baseTtl, double jitterPercent = 0.2)
{
var jitterRange = baseTtl.TotalSeconds * jitterPercent;
var jitterOffset = (_rng.NextDouble() * 2 - 1) * jitterRange; // ±jitterRange
var jitteredSecs = baseTtl.TotalSeconds + jitterOffset;
return TimeSpan.FromSeconds(Math.Max(jitteredSecs, 1)); // minimum 1 second
}
public static Task SetWithJitterAsync(
this IDistributedCache cache,
string key, byte[] value, TimeSpan baseTtl,
CancellationToken ct = default)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = baseTtl.WithJitter()
};
return cache.SetAsync(key, value, options, ct);
}
}
// Usage — product detail with jittered 10-minute TTL
await cache.SetWithJitterAsync(
CacheKeys.ProductDetail(id), serialized,
TimeSpan.FromMinutes(10), ct);
// Actual TTL: 8–12 minutes — expirations spread, stampede risk eliminated
Jitter Is Proportional, Not Absolute
A fixed jitter offset (±30 seconds) is ineffective for short TTLs — ±30 seconds on a 60-second TTL is 50% variance, which may be too much. Express jitter as a percentage of the base TTL (10–20%) so it scales appropriately. For a 5-minute TTL, 20% jitter gives ±60 seconds — enough to spread expirations without causing unacceptable staleness. For a 30-minute TTL, 20% jitter gives ±360 seconds — which is reasonable for slowly changing reference data like category lists.
A cache you cannot observe is a cache you cannot trust or optimise. Two instruments provide visibility: hit/miss metrics for dashboards and alerting, and Cache-Status response headers for debugging individual requests without opening a profiler.
Cache/MeteredOutputCacheStore.cs — Wrap the Store to Emit Metrics
public class MeteredOutputCacheStore : IOutputCacheStore
{
private readonly IOutputCacheStore _inner;
private readonly ILogger _logger;
private readonly Counter _hits;
private readonly Counter _misses;
public MeteredOutputCacheStore(
IOutputCacheStore inner,
IMeterFactory meterFactory,
ILogger logger)
{
_inner = inner;
_logger = logger;
var meter = meterFactory.Create("CatalogApi");
_hits = meter.CreateCounter("cache.hits");
_misses = meter.CreateCounter("cache.misses");
}
}
public async ValueTask<ReadOnlyMemory<byte>?> GetAsync(
string key, CancellationToken ct)
{
var result = await inner.GetAsync(key, ct);
if (result.HasValue)
{
_hits.Add(1, new KeyValuePair<string, object?>("store", "output"));
logger.LogDebug("Cache HIT: {Key}", key);
}
else
{
_misses.Add(1, new KeyValuePair<string, object?>("store", "output"));
logger.LogDebug("Cache MISS: {Key}", key);
}
return result;
}
public ValueTask SetAsync(string key, ReadOnlyMemory<byte> value,
string[]? tags, TimeSpan validFor, DateTimeOffset createdAt, CancellationToken ct) =>
inner.SetAsync(key, value, tags, validFor, createdAt, ct);
public ValueTask EvictByTagAsync(string tag, CancellationToken ct) =>
inner.EvictByTagAsync(tag, ct);
}
// Register the wrapper — decorates the Redis store transparently
// Option A: using Scrutor (dotnet add package Scrutor)
builder.Services.AddStackExchangeRedisOutputCache(/* ... */);
builder.Services.Decorate();
// Option B: without Scrutor — register manually
builder.Services.AddStackExchangeRedisOutputCache(/* ... */);
builder.Services.AddSingleton(sp =>
{
// Resolve the Redis store registered by AddStackExchangeRedisOutputCache
// Note: use a keyed service or factory pattern to avoid circular resolution
var inner = sp.GetRequiredKeyedService("redis"); // if keyed
var factory = sp.GetRequiredService();
var logger = sp.GetRequiredService>();
return new MeteredOutputCacheStore(inner, factory, logger);
});
// Adds Cache-Status header to every response for debugging without a profiler
// Example headers:
// Cache-Status: hit; age=42 ← served from cache, 42 seconds old
// Cache-Status: miss ← cache miss, response came from origin
// Cache-Status: bypass ← caching skipped (write method, authenticated, etc.)
public class CacheStatusHeaderMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
context.Response.OnStarting(() =>
{
if (context.Response.Headers.ContainsKey("Age"))
{
var age = context.Response.Headers["Age"];
context.Response.Headers["Cache-Status"] = $"hit; age={age}";
}
else if (HttpMethods.IsGet(context.Request.Method) ||
HttpMethods.IsHead(context.Request.Method))
{
context.Response.Headers["Cache-Status"] = "miss";
}
else
{
context.Response.Headers["Cache-Status"] = "bypass";
}
return Task.CompletedTask;
});
await next(context);
}
}
// Register BEFORE UseOutputCache so it can observe the Age header Output Cache sets
app.UseMiddleware<CacheStatusHeaderMiddleware>();
app.UseOutputCache();
Monitor Hit Rate, Not Just Latency
The most important cache metric is hit rate: the percentage of requests served from cache versus origin. A hit rate below 70% on a GET endpoint that should be heavily cached suggests vary rules are too broad (generating too many distinct keys), the TTL is too short, or invalidation is too aggressive. Track hit rate per endpoint — a 90% overall hit rate can hide a critical endpoint with 10% hit rate hammering your database. Expose cache.hits and cache.misses counters to Prometheus and alert when hit rate drops below your established baseline.
Use the Age Header to Diagnose Stale Responses in Production
The Age response header, set automatically by Output Cache, tells you how many seconds old a cached response is. If you see Age: 580 on an endpoint with a 5-minute TTL after invalidation ran 3 minutes ago, something is wrong — either the eviction did not reach all instances (check Redis connectivity), or the cache key the eviction targeted did not match the key stored in Redis (check key prefix and vary configuration). The Age header is your first diagnostic signal in production without needing to attach a profiler or enable verbose logging.
Frequently Asked Questions
What is the difference between Output Cache and IDistributedCache in ASP.NET Core?
Output Cache operates at the HTTP response level — it stores the full serialized HTTP response and short-circuits the entire middleware pipeline on a cache hit. You configure it with policies, vary rules, and tags on your endpoints. IDistributedCache operates at the application data level — you store and retrieve arbitrary byte arrays keyed by a name you choose. Output Cache is the right tool for caching GET endpoint responses as a unit. Redis-backed IDistributedCache is the right tool for caching computed results or deserialized objects shared across application components. They solve different problems and are frequently used together in the same API.
Why does in-memory Output Cache break in a multi-instance deployment?
By default, Output Cache stores responses in each instance's own process memory. In a three-instance deployment, a cache fill on Instance A does not populate B or C. Worse, a tag eviction triggered by a product update on Instance B leaves stale entries alive on A and C. Users see stale or fresh data depending on which instance the load balancer routes them to. Redis as the Output Cache backing store solves this: all instances share one store, fills are immediately visible everywhere, and tag evictions propagate atomically to all instances.
When should I use tag-based invalidation vs key-pattern invalidation?
Tag-based invalidation is the correct default for Output Cache — tag each response at registration time, call EvictByTagAsync when data changes, done. No knowledge of specific keys needed, atomic across Redis. Key-pattern invalidation is appropriate for IDistributedCache where you manage keys manually — namespace keys predictably and delete by exact key on mutation. Version tokens are a variation: embed a version counter in keys and increment it to invalidate an entire namespace in one atomic O(1) operation. Choose tag-based for Output Cache, key-pattern or version tokens for IDistributedCache, and event-driven invalidation when mutations originate outside your service.
What is a cache stampede and how do I prevent it?
A cache stampede occurs when a popular cached value expires and many concurrent requests simultaneously find a cache miss — all firing the expensive origin query at once. ASP.NET Core Output Cache includes built-in request coalescing: when concurrent requests match the same cache key, only the first fires through to the handler — the rest wait and receive the same completed response, resulting in one database query instead of hundreds. For IDistributedCache, use jittered TTLs — randomise expiry by ±10–20% of the base TTL — to spread expirations across time and prevent synchronized stampedes.
Should I cache authenticated endpoint responses?
Output Cache will not cache responses with Set-Cookie headers or requests carrying Authorization headers by default — this is the correct safe behaviour. The practical rule: cache unauthenticated public resources aggressively, cache user-specific resources only when you can derive a stable vary key from the identity (such as a user ID from the token's sub claim), and never allow one user's data to be served from a cache entry populated by a different user's request. The last rule is not a recommendation — it is a security requirement.