The Cache Hit That Lied: A .NET 9 Playbook for ASP.NET Core Output Caching, ETag Revalidation, and Safe Invalidation

ASP.NET Core output caching architecture with ETag revalidation and cache invalidation tags

At 9:07 on a Monday, our support chat lit up with a familiar message: “Prices changed, but product cards still show yesterday’s discount.” The database had the new values. The admin panel had the new values. The API logs showed healthy 200s. Still, users were seeing stale data, and only some of them.

What finally fixed it was not “turn cache off” or “make TTL tiny.” It was a deliberate caching contract: what can be cached, for how long, what must vary by request, and exactly which writes invalidate which responses. In .NET 9, ASP.NET Core output caching gives you the right building blocks, but you still need to choose the policy boundaries carefully.

This post is a field guide for teams that want faster APIs without silently serving the wrong thing. If your focus is .NET 9 API performance, this is where caching stops being magic and starts becoming engineering.

The caching split that most teams miss

ASP.NET Core has both response caching and output caching. They solve different problems.

  • Response caching follows HTTP request/response headers closely, so clients can influence behavior.
  • Output caching is server-driven policy. You decide what gets cached and clients do not override it by default.

That distinction matters for business endpoints where consistency beats raw hit rate. The Microsoft docs are explicit that output caching is configurable server-side and suitable across Minimal APIs, controllers, and Razor Pages. HTTP semantics from RFC 9111 still matter, especially when you add ETag revalidation and standard cache headers for downstream caches.

A practical contract before writing code

Before adding middleware, write a one-page contract per endpoint family:

  • Volatility: how often does data change, and what is the maximum acceptable staleness?
  • Personalization: is response content user-specific (never cache shared output)?
  • Vary dimensions: which query keys, route segments, language, or tenant headers change payload shape?
  • Invalidation owner: which command handler or event publishes cache-evict tags?

Without this, teams usually over-vary keys (bad hit rate) or under-vary keys (wrong data leakage). Both fail in production, just in different ways.

Implementation pattern: policy + tags + revalidation

The following pattern works well for read-heavy catalog/search APIs where writes are less frequent but correctness still matters.

using Microsoft.AspNetCore.OutputCaching;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOutputCache(options =>
{
    // Conservative default for safe GET endpoints.
    options.AddBasePolicy(p => p
        .Cache()
        .Expire(TimeSpan.FromSeconds(30))
        .SetVaryByQuery("page", "pageSize", "sort", "q")
        .Tag("catalog"));

    // Product details can live longer but must vary by locale + tenant.
    options.AddPolicy("product-details", p => p
        .Expire(TimeSpan.FromMinutes(2))
        .SetVaryByRouteValue("id")
        .SetVaryByHeader("X-Tenant", "Accept-Language")
        .Tag("catalog")
        .Tag("product-details"));
});

var app = builder.Build();

app.UseHttpsRedirection();
app.UseOutputCache();

app.MapGet("/api/products/{id:guid}",
    async (Guid id, HttpContext http, IProductReadService svc) =>
    {
        var dto = await svc.GetProductAsync(id, http.RequestAborted);
        if (dto is null) return Results.NotFound();

        // Strong ETag from version marker (for 304 revalidation paths).
        var etag = $"\"{dto.RowVersionHex}\"";
        http.Response.Headers.ETag = etag;
        http.Response.Headers.CacheControl = "public,max-age=60,stale-while-revalidate=30";

        if (http.Request.Headers.IfNoneMatch == etag)
            return Results.StatusCode(StatusCodes.Status304NotModified);

        return Results.Ok(dto);
    })
   .CacheOutput("product-details");

app.Run();

Then invalidate by tags on writes. This is your cache invalidation strategy, and it must live next to your update path, not in a separate “ops script” nobody remembers.

app.MapPut("/api/products/{id:guid}",
    async (Guid id,
           UpdateProductRequest req,
           IProductWriteService writer,
           IOutputCacheStore cache,
           CancellationToken ct) =>
    {
        var result = await writer.UpdateAsync(id, req, ct);
        if (!result.Found) return Results.NotFound();

        // Evict only affected tag groups.
        await cache.EvictByTagAsync("product-details", ct);
        await cache.EvictByTagAsync("catalog", ct);

        return Results.NoContent();
    });

Where teams usually get burned

1) Authenticated endpoints accidentally cached

Default output caching rules are conservative, but custom policies can override behavior. Be explicit: keep user-personalized responses out of shared cache unless you have strict vary keys and a compelling reason.

2) Missing vary headers for multi-tenant or locale-aware content

If tenant or locale changes payload shape, include those headers in vary rules. Otherwise one tenant can receive another tenant’s projection. This is subtle and high risk.

3) Treating stale directives as a free uptime hack

stale-while-revalidate and stale-if-error can smooth latency and outages, but they are tradeoffs, not magic. RFC 5861 is clear that stale windows should be bounded. For prices, inventory, or permissions, your stale budget is usually much smaller than for blog lists or marketing pages.

4) No observability on hit ratio and stale serves

If you do not measure hit/miss, revalidation rate, and eviction frequency, you will tune blindly. Start with endpoint-level counters and alert on sudden hit-ratio collapse after deploys.

Troubleshooting checklist (the one we actually use)

  1. Wrong content returned: dump vary dimensions and compare two requests that should differ (tenant, locale, query, route).
  2. Cache never hits: check if endpoint sets cookies or auth context unexpectedly, and confirm policy attachment with CacheOutput/[OutputCache].
  3. Stale data after writes: verify write path emits tag eviction every time, including retries and bulk import flows.
  4. Latency spikes during expiry: inspect stampede patterns; tighten key design and avoid synchronized expirations on huge endpoint groups.
  5. Unexpected client behavior: inspect full request headers. Some clients force revalidation; your server-side output policy still needs clean HTTP semantics.

How this fits with the rest of your stack

Output caching should complement, not replace, good API design. Pair it with:

That combination keeps read paths fast while preserving correctness when writes arrive in bursts.

One final tradeoff to state plainly: higher cache TTLs reduce origin load but increase the blast radius of bad invalidation. Lower TTLs reduce stale risk but can create thundering-herd behavior during traffic spikes. Start conservative, measure, and move gradually. In practice, teams that revisit TTL and vary rules every sprint get better long-term reliability than teams that treat caching as a one-time tuning task.

FAQ

Should I use output caching or response caching for public APIs?

Use output caching when you need server-controlled behavior and predictable policies regardless of client headers. Use response caching when strict HTTP cache semantics driven by headers are exactly what you want.

Can I cache authenticated GET endpoints?

You can, but do it only with explicit vary dimensions and careful privacy review. For most teams, caching authenticated personalized payloads in shared output cache is not worth the risk.

Do ETags make output caching unnecessary?

No. They solve different parts of the problem. Output caching reduces server work for repeated requests. ETags make revalidation cheaper when clients or intermediaries ask whether data changed.

Actionable takeaways

  • Adopt one written cache contract per endpoint family before coding policies.
  • Use ASP.NET Core output caching for server-owned behavior, then layer standards-friendly headers for intermediaries.
  • Define and test a tag-based cache invalidation strategy in every write path.
  • Add ETag revalidation for high-traffic resources to cut payload transfer and improve conditional request behavior.
  • Track hit ratio, stale serves, and eviction latency so tuning is evidence-based.

Primary keyword: ASP.NET Core output caching
Secondary keywords: .NET 9 API performance, ETag revalidation, cache invalidation strategy

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Privacy Policy · Contact · Sitemap

© 7Tech – Programming and Tech Tutorials