⚡ Hands-On Tutorial

ASP.NET Core Caching: Output Cache, Redis & Invalidation Strategies That Actually Work

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:";
});
docker-compose.yml — Local Redis for Development
services:
  catalog-api:
    build: .
    ports: ["5000:8080"]
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__Redis=redis:6379
    depends_on:
      redis:
        condition: service_healthy

  redis:
    image: redis:7.2-alpine
    ports: ["6379:6379"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    command: redis-server --save "" --appendonly no
Use a Key Namespace Prefix in Every Environment

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.

Events/ProductChangedEvent.cs & ICacheInvalidator.cs
public record ProductChangedEvent(
    int               ProductId,
    ProductChangeType ChangeType,
    DateTimeOffset    OccurredAt);

public enum ProductChangeType { Created, Updated, Deleted, PriceChanged }

// Abstraction over the invalidation mechanism — keeps domain code ignorant of
// whether invalidation uses Output Cache tags, version tokens, or pub/sub
public interface ICacheInvalidator
{
    Task InvalidateProductAsync(int productId, CancellationToken ct = default);
    Task InvalidateAllProductsAsync(CancellationToken ct = default);
    Task InvalidateCategoriesAsync(CancellationToken ct = default);
}
Cache/CacheInvalidationWorker.cs — Background Listener
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.
JitteredCache.cs — Spread IDistributedCache Expirations
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.

Observability: Hit/Miss Metrics & Cache-Status Headers

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);
});
Middleware/CacheStatusHeaderMiddleware.cs — Debug Response Header
// 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.

Back to Tutorials