ASP.NET Core Output Cache in Depth: Policies, Vary-By, Tag Eviction, Resource Locking and the Redis Store
Policies, vary-by rules and middleware order decide whether output cache is safe and effective: in Bookstore, resource locking ran the handler once for 100 concurrent requests (100 times with locking off), and placing UseOutputCache before authentication served Ada's personal recommendations to an anonymous user and to Grace on the 10.0.3 runtime. The Redis store gives two instances a shared cache and eviction, while locking stays per instance.
Version note: The code in this post was built and run with .NET 10; the examples ran on the ASP.NET Core 10.0.13 runtime with Microsoft.AspNetCore.OutputCaching.StackExchangeRedis 10.0.14 and Redis 8.2. One comparison in the middleware order section was deliberately run on the older 10.0.3 runtime.
Guess First
Bookstore has an endpoint that returns personal book recommendations to the signed-in user: /api/me/recommendations. It is marked with RequireAuthorization() and CacheOutput(). During a refactor, the app.UseOutputCache() line moves above app.UseAuthentication() and nobody notices. Ada signs in and opens her recommendations, then an anonymous visitor requests the same URL (Uniform Resource Locator), then Grace does.
Three questions. What does the anonymous visitor see: a 401, or Ada's recommendations? What does Grace see? Does the answer depend on which patch of the .NET 10 runtime the server runs? And a bonus: a sales report endpoint that takes 300 ms is output-cached, the cache is empty, and 100 requests arrive at once. How many times does the handler run?
Policies: Base, Named and Your Own IOutputCachePolicy
The introduction to output cache was in the ASP.NET Core Web API (Application Programming Interface) series, "What's New in ASP.NET Core with .NET 7: MapGroup, TypedResults, Rate Limiting and Output Caching": AddOutputCache, UseOutputCache, CacheOutput and the Age header. The HTTP (Hypertext Transfer Protocol) side, Cache-Control, ETag and 304, was the subject of the fifth post in this series. Here we set up output cache for real in Bookstore's catalog API and measure every behavior with a handler execution counter: every response carries an execution number, and if the same number shows up twice, the response came from the cache.
builder.Services.AddOutputCache(options =>
{
// base policy: every /api/books response carries the "books" tag
options.AddBasePolicy(b => b
.With(c => c.HttpContext.Request.Path.StartsWithSegments("/api/books"))
.Tag("books"));
options.AddPolicy("Book", b => b.AddPolicy<BookTagPolicy>().Expire(TimeSpan.FromMinutes(10)));
options.AddPolicy("BookList", b => b.SetVaryByQuery("page", "sort").Expire(TimeSpan.FromMinutes(2)));
options.AddPolicy("Menu", b => b.SetVaryByHeader("Accept-Language").Expire(TimeSpan.FromMinutes(30)));
options.AddPolicy("Prices", b => b
.VaryByValue(ctx => new KeyValuePair<string, string>("currency", ctx.Request.Cookies["currency"] ?? "USD"))
.Expire(TimeSpan.FromMinutes(1)));
options.AddPolicy("Report", b => b.Expire(TimeSpan.FromSeconds(30)));
options.AddPolicy("ReportNoLock", b => b.Expire(TimeSpan.FromSeconds(30)).SetLocking(false));
options.AddPolicy("MembersShared", new MembersSharedPolicy());
options.MaximumBodySize = 64 * 1024;
});
There are three layers. The base policy applies to every request; here With narrows it to the /api/books path. An endpoint selects a named policy with .CacheOutput("Book"), and it is added on top of the base policy. The third layer is the invisible one: OutputCachePolicyBuilder puts Microsoft's default policy inside itself by default. The "only 200, only GET and HEAD, no Set-Cookie, not authenticated" rules come from there, and it adds the whole query string to the key. If you don't want it, use the overloads of AddPolicy, AddBasePolicy and CacheOutput that take excludeDefaultPolicy.
That invisible layer has a cost. Following the "evict everything with one tag" example on Learn, I wrote options.AddBasePolicy(b => b.Tag("all")) and called the /stats endpoint, which returns the counters, twice with a book request in between:
$ curl -s -i localhost:17531/stats | grep -E "Age|\{"
{}
$ curl -s localhost:17531/api/books/1 > /dev/null
$ curl -s -i localhost:17531/stats | grep -E "Age|\{"
Age: 0
{}
The book handler ran, yet /stats still returns empty counters, because the base policy brought the default policy with it and made every GET endpoint in the app cacheable. Written with excludeDefaultPolicy: true, the same line left /stats uncached, the tag was still added to every entry, and evicting the all tag removed the book entry.
When the rules aren't enough, you write IOutputCachePolicy yourself. The interface's three methods map to three moments of a request: CacheRequestAsync before the handler (permission for lookup and storage, key, tags, duration), ServeFromCacheAsync while a response is served from the cache, and ServeResponseAsync after the handler finishes but before the response is stored (to back out based on status code and headers). A policy that adds a dynamic tag to book details using the id from the route:
// adds a per-book tag ("book:42") from the route id, so one book can be evicted on its own
public sealed class BookTagPolicy : IOutputCachePolicy
{
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
if (context.HttpContext.Request.RouteValues["id"] is string id)
context.Tags.Add($"book:{id}");
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
}
Tag("...") only takes constant strings; a policy like this is the only way to get a tag that varies per entity. IOutputCachePolicyProvider, which resolves policies dynamically per request, arrives with .NET 11; we'll look at it in the tenth post of the series. Let's also pin down the difference between the response caching from the Web API series and output cache, because they are two different answers to the same problem:
| Topic | Response caching | Output cache |
|---|---|---|
| Who decides | HTTP headers; the client's no-cache bypasses the server cache | The policy on the server; client headers can't bypass it |
| Storage | Process memory only | IOutputCacheStore: memory, Redis or your own store |
| Programmatic removal | None | EvictByTagAsync with tags |
| Stampede protection | None | Resource locking, on by default |
| 304 revalidation | None | Automatic with ETag and If-Modified-Since |
What the Default Rules Really Do
I tried the default policy's four rules with a script: two identical requests per scenario, then how many times the handler ran. Each row shows the status code, the Age header (- if absent) and whether the response carried Set-Cookie:
GET /api/books/7 200 age=- cookie=no 200 age=0 cookie=no runs=1
GET /api/books/999 (404) 404 age=- cookie=no 404 age=- cookie=no runs=2
GET /api/home (new visitor) 200 age=- cookie=yes 200 age=- cookie=yes runs=2
GET /api/home (Cookie: visitor_id) 200 age=- cookie=no 200 age=0 cookie=no runs=1
GET /api/home (new visitor again) 200 age=0 cookie=no 200 age=0 cookie=no runs=0
GET /api/me/recommendations (ada) 200 age=- cookie=no 200 age=- cookie=no runs=2
GET /api/bestsellers (Authorization) 200 age=- cookie=no 200 age=- cookie=no runs=2
GET /api/catalog/export (93 KB) 200 age=- cookie=no 200 age=- cookie=no runs=2
The expected parts: the 404 wasn't stored, Ada's request with a session cookie wasn't stored, and the 93 KB export was silently not stored because it exceeds MaximumBodySize (64 KB here, 64 MB by default). The request with an Authorization header didn't even look at the cache; when the default policy sees that header it turns off both lookup and storage. That is why output cache is effectively off for an API called with bearer tokens.
The interesting part is /api/home. The endpoint gives first-time visitors a visitor_id cookie for analytics. Since the new visitor's response carries Set-Cookie, neither request was cached. The returning visitor's response has no Set-Cookie, so it was stored. Then another new visitor arrived and the handler didn't run at all: the cached response came back, without Set-Cookie. As long as the cache is warm, no new visitor gets that cookie. Because output cache answers before the handler runs, every side effect that decorates the response (cookies, counters, logs) disappears on a cache hit.
Vary-By: What Goes Into the Key and What Doesn't
The default key consists of the scheme, host, port, path and the entire query string. The path is case-insensitive (UseCaseSensitivePaths changes that). Headers and cookies are not part of the key. The script prints each request's execution number and Age:
GET /api/bestsellers?utm_source=mail execution=1 {"top":[7,42,13]}
GET /api/bestsellers?utm_source=social execution=2 {"top":[7,42,13]}
GET /api/books?page=2&utm_source=mail execution=1 {"items":20}
GET /api/books?page=2&utm_source=social execution=1 {"items":20} Age: 0
GET /api/books?page=2&sort=price execution=2 {"items":20}
GET /api/categories/menu (Accept-Language: tr-TR) execution=1 {"first":"Roman"}
GET /api/categories/menu (Accept-Language: en-US) execution=2 {"first":"Fiction"}
GET /api/categories/menu (Accept-Language: tr-TR) execution=1 {"first":"Roman"} Age: 0
GET /api/prices-naive/5 (Cookie: currency=USD) execution=1 {"id":5,"currency":"USD","price":15}
GET /api/prices-naive/5 (Cookie: currency=EUR) execution=1 {"id":5,"currency":"USD","price":15} Age: 0
GET /api/prices/5 (Cookie: currency=USD) execution=1 {"id":5,"currency":"USD","price":15}
GET /api/prices/5 (Cookie: currency=EUR) execution=2 {"id":5,"currency":"EUR","price":13.8}
The first two lines show what the default policy does to campaign links: every value of a UTM (Urchin Tracking Module) parameter is a separate entry, so every newsletter and every social media share fills the cache from scratch. SetVaryByQuery("page", "sort") in the BookList policy restricted the key to those two parameters; a different utm_source returned the same entry, a different sort created a new one.
SetVaryByHeader("Accept-Language") split the menu by language. A caveat: the raw header value goes into the key. Browsers send dozens of combinations like tr-TR,tr;q=0.9,en-US;q=0.8, and each becomes its own entry. If the app supports two languages anyway, putting the resolved culture into the key with VaryByValue instead of the header brings the entry count down to two.
The last four lines are the most expensive mistake in this post. /api/prices-naive computes the price from the currency cookie, but the default policy doesn't add the cookie to the key. The user asking for euros got the previous user's dollar response. A request cookie doesn't make a response uncacheable (only Set-Cookie on the response does), so every cookie, header and claim the handler reads either goes into the key or the endpoint isn't cached. The fixed Prices policy adds the cookie value to the key with VaryByValue, and the two currencies become two entries.
Tag Eviction
When a price changes, we want to remove only that book's detail response. The admin endpoint injects IOutputCacheStore and evicts the tag that BookTagPolicy added:
app.MapPut("/api/admin/books/{id:int}/price", async (int id, decimal price, Catalog catalog, IOutputCacheStore store, CancellationToken ct) =>
{
if (!catalog.UpdatePrice(id, price)) return Results.NotFound();
await store.EvictByTagAsync($"book:{id}", ct);
return Results.NoContent();
});
GET /api/books/42 price=12 execution=1
GET /api/books/43 price=13 execution=2
GET /api/books?page=1 execution=1
PUT price of book 42 (evict book:42) 204
GET /api/books/42 price=19.9 execution=3
GET /api/books/43 price=13 execution=2 Age: 0
GET /api/books?page=1 execution=1 Age: 0
POST evict tag books 204
GET /api/books/43 price=13 execution=4
GET /api/books?page=1 execution=2
Book 42's detail was regenerated, while 43 and the list page stayed in the cache. The limit of precise eviction shows up here too: if the list page also contains book 42's price, it keeps showing the old one. Either you tag lists with the tags of every book they contain (20 tags per page, hard to manage), or on a price change you evict the coarse books tag the base policy adds. Bookstore's prices change a few times a day, so we picked the second option; regenerating the list every two minutes is cheap.
In the fourth post of the series we saw that HybridCache's RemoveByTagAsync is logical and values stay until they expire. Output cache's two stores are different: the memory store really removes the tagged keys from MemoryCache, and the Redis store scans the tag's sorted set and deletes the value keys. But the memory store lives inside the process; in a three-instance deployment, the instance that receives the eviction only removes its own copy.
Resource Locking: One Execution per Response
The report question from the guess. Two endpoints do the same work (wait 300 ms, return a report), one with the default Report policy, the other with ReportNoLock, which adds SetLocking(false). A small console app starts 100 requests at the same moment; I ran it three times, restarting the app each round, and all three rounds gave the same result:
100 concurrent GET /api/reports/sales: 200 x100
100 concurrent GET /api/reports/sales-nolock: 200 x100
{"report":1,"report-nolock":100}
The answer to the guess: 1. With locking on, the middleware sends the first request for a key to the handler, holds the other 99 until it finishes and gives them the same response. With locking off, all 100 requests hitting the empty cache ran the handler; if an expensive SQL (Structured Query Language) query sat behind the report, the database would have seen it 100 times. The theory of stampedes is in the System Design series post "Caching Strategies: Cache-Aside, Write-Through, Write-Behind and Cache Stampede"; output cache gives you the protection for free, so you need a measured reason to turn it off.
Middleware Order: Serving Cached Content to Unauthorized Users
Learn's rule is clear: in an app that uses authentication and authorization, UseOutputCache comes after UseAuthentication and UseAuthorization, and after UseCors if CORS (Cross-Origin Resource Sharing) is in use. The wrong and the right order from the guess:
// wrong: the cache lookup happens before authentication and authorization
app.UseOutputCache();
app.UseAuthentication();
app.UseAuthorization();
// right
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();
Authentication in the sample is a small authentication handler that reads a bookstore_session cookie; there is no Authorization header, just like in a cookie-based web app. I ran the same scenario with the wrong order both deliberately on the older 10.0.3 runtime and on this post's runtime:
== order=wrong
runtime 10.0.3
ada GET /api/me/recommendations 200 {"user":"ada","picks":["Book for ada"]} execution=1
anon GET /api/me/recommendations 200 {"user":"ada","picks":["Book for ada"]} execution=1
grace GET /api/me/recommendations 200 {"user":"ada","picks":["Book for ada"]} execution=1
ada GET /api/members/deals 200 {"deal":"Members save 20%"} execution=1
anon GET /api/members/deals 200 {"deal":"Members save 20%"} execution=1
== order=wrong
runtime 10.0.13
ada GET /api/me/recommendations 200 {"user":"ada","picks":["Book for ada"]} execution=1
anon GET /api/me/recommendations 401
grace GET /api/me/recommendations 200 {"user":"grace","picks":["Book for grace"]} execution=2
ada GET /api/members/deals 200 {"deal":"Members save 20%"} execution=1
anon GET /api/members/deals 200 {"deal":"Members save 20%"} execution=1
On 10.0.3 the worst answer to the guess came true: both the anonymous visitor and Grace got Ada's personal recommendations. The cause is the order. When the output cache middleware runs, authentication hasn't run yet, User is anonymous and there's no Authorization header, so the default policy treats the request as cacheable. The handler produces a response for Ada, and the response is stored. Later requests are answered from the cache without ever reaching authorization.
On 10.0.13 the personal recommendations didn't leak. The dotnet/aspnetcore repository has a security fix titled "Avoid caching responses for authenticated users"; looking at the tags, the check that is missing in v10.0.9 shows up in v10.0.10. The default policy now also checks whether the user is authenticated before storing the response. But the second endpoint still leaks. /api/members/deals shows the same content to every member, so it uses a custom policy that deliberately caches authenticated requests:
// the same content for every signed-in member: caching authenticated requests is a deliberate choice here
public sealed class MembersSharedPolicy : IOutputCachePolicy
{
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
bool isGet = HttpMethods.IsGet(context.HttpContext.Request.Method);
context.EnableOutputCaching = true;
context.AllowCacheLookup = isGet;
context.AllowCacheStorage = isGet;
context.AllowLocking = true;
context.ResponseExpirationTimeSpan = TimeSpan.FromMinutes(5);
context.Tags.Add("deals");
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
HttpResponse response = context.HttpContext.Response;
if (response.StatusCode != StatusCodes.Status200OK || response.Headers.SetCookie.Count > 0)
context.AllowCacheStorage = false;
return ValueTask.CompletedTask;
}
}
The policy is fine; the order is the bug. Because the cache lookup runs before authorization, the cached member content went back to the anonymous request with a 200. With the right order on the same runtime, the anonymous request got a 401 on both endpoints and Grace saw her own recommendations. A patched runtime is a safety net; it doesn't replace the correct order.
Multiple Servers with the Redis Store
With the memory store, each instance has its own cache and its own evictions. For a shared store you use AddStackExchangeRedisOutputCache from a separate package, not AddStackExchangeRedisCache:
builder.Services.AddStackExchangeRedisOutputCache(o =>
{
o.Configuration = "localhost:17530,asyncTimeout=500";
o.InstanceName = "bookstore:";
});
I connected two instances (A: 17531, B: 17532) to the same Redis. On the first attempt I sent requests straight to localhost:17531 and localhost:17532, and B ran the handler again instead of using the response A had cached. Redis had two separate value keys; the key contains the host and port. Behind a load balancer both instances see the same public host; simulating that in the test with a Host: shop.bookstore.test header:
A: GET /api/books/42 renderedBy=A execution=1
B: GET /api/books/42 renderedBy=A execution=1 Age: 0
stats A={"book":1} B={}
B: POST evict book:42 -> 204
A: GET /api/books/42 renderedBy=A execution=2
B: GET /api/books/42 renderedBy=A execution=2 Age: 0
B returned the response A produced without running its handler, and the eviction done on B cleared A's cache too. The structure in Redis:
$ docker exec chwork-redis-c redis-cli --no-raw --scan | sort
"bookstore:__MSOCT"
"bookstore:__MSOCT_book:42"
"bookstore:__MSOCT_books"
"bookstore:__MSOCV_GET\x1eHTTP\x1eSHOP.BOOKSTORE.TEST/API/BOOKS/42\x1eQ\x1e*="
$ docker exec chwork-redis-c redis-cli --no-raw ZRANGE 'bookstore:__MSOCT_book:42' 0 -1 WITHSCORES
1) "GET\x1eHTTP\x1eSHOP.BOOKSTORE.TEST/API/BOOKS/42\x1eQ\x1e*="
2) "1794104918113"
The value key is a string; its TTL (Time to Live) is the policy's 10 minutes (600 s) and its size is 188 bytes. The Q\x1e*= at the end of the key is the default policy's "all query parameters" rule. Each tag is a sorted set: its members are cache keys, its scores are expiry times in milliseconds. According to the package source, the store runs a loop roughly every five minutes that removes expired tag members. This structure is also why Microsoft Learn doesn't recommend IDistributedCache for output cache: tagging needs atomic operations, and IDistributedCache's interface doesn't have them. If you need a store other than Redis, you write IOutputCacheStore directly against that storage.
Resource locking, however, doesn't move to Redis. I sent 50 requests to each of the two instances at the same time on an empty cache, with the same result in all three rounds:
50 concurrent GET /api/reports/sales: 200 x50
50 concurrent GET /api/reports/sales: 200 x50
stats A={"report":1} B={"report":1}
The lock is in-process; N instances run the handler at most N times. For most endpoints that's an acceptable number. If you truly need a single computation, you need a distributed lock, which we'll cover together with stampedes in the eighth post of the series.
Production Notes
With the Redis store, every cache hit is a network round trip. I tested what happens when Redis slows down by freezing the container with docker pause. The middleware swallows the error and falls through to the handler, so the request succeeds, but it waits first:
== redis=localhost:17530
200 11.943515s
200 11.984493s
Unable to query output cache.
Timeout awaiting response (outbound=0KiB, inbound=0KiB, 5941ms elapsed, timeout is 5000ms), command=GET, next: G
Unable to write to output-cache.
Timeout awaiting response (outbound=0KiB, inbound=0KiB, 5980ms elapsed, timeout is 5000ms), command=SETEX, next:
== redis=localhost:17530,asyncTimeout=500
200 1.962664s
200 1.983567s
Unable to query output cache.
Timeout awaiting response (outbound=0KiB, inbound=0KiB, 959ms elapsed, timeout is 500ms), command=GET, next: GET
Unable to write to output-cache.
Timeout awaiting response (outbound=0KiB, inbound=0KiB, 984ms elapsed, timeout is 500ms), command=SETEX, next: G
The log shows Unable to query output cache and then Unable to write to output-cache; the read and the write each hit the timeout, so the request took 12 seconds. With asyncTimeout=500 it dropped to 2 seconds, not 1: according to the StackExchange.Redis docs, async timeouts are checked on the HeartbeatInterval (1000 ms by default). Failing faster takes a circuit breaker; staying up while Redis is down is a topic for the eighth post.
The memory store's SizeLimit defaults to 100 MB, and once it's full, new responses aren't stored until older ones leave. MaximumBodySize defaults to 64 MB; a single big export can eat half the cache, so look at your real response sizes and lower it. Watch deployments too: the key doesn't contain the app version. When a release changes the JSON (JavaScript Object Notation) shape, the old responses in Redis are served from the new instances until their TTL runs out; after a deploy, evict an all tag added with excludeDefaultPolicy: true, or put the version into the key with VaryByValue. The share of responses carrying an Age header is a good first hit-ratio measure; detailed measurement comes in the ninth post.
Common Pitfalls
Putting UseOutputCache before authentication. A patched runtime protects the default policy; it doesn't protect policies that cache authenticated requests on purpose.
Writing AddBasePolicy(b => b.Tag("all")). The base policy brings the default policy along and every GET endpoint becomes cacheable; if you only want to add a tag, use excludeDefaultPolicy: true.
Leaving a cookie or header the handler reads out of the key. Currency, language, tenant, A/B test group: each either goes into vary-by or the endpoint isn't cached.
Putting side effects in a cached endpoint. Cookies, counters and logs don't run on a cache hit; move work like handing a cookie to new visitors to an uncached endpoint or to the client.
Instances seeing different Host values. Even with a shared Redis store, the key contains the host, so requests arriving through an internal address create separate entries.
Leaving the purge endpoint open. The admin endpoints in the sample have no authorization to keep them short; in a real app every endpoint that calls EvictByTagAsync needs authorization, otherwise anyone can empty your cache and push the load onto the database.
A Small Experiment
★☆☆ Call /api/categories/menu with Accept-Language: tr-TR,tr;q=0.9 and with Accept-Language: tr. How many entries were created? Then replace SetVaryByHeader with a VaryByValue that returns the resolved culture and count again.
★★☆ Fix the cookie problem in /api/home: move the code that hands out the cookie to a separate middleware and run that middleware before UseOutputCache. Does the new visitor get the cookie, and does /api/home still come from the cache? Explain what you see with the default policy's Set-Cookie rule.
★★★ Write a decorator around IOutputCacheStore: count a hit or a miss on every GetAsync call and log the tag on every EvictByTagAsync. Run the report with 100 requests on the two-instance Redis setup and calculate the hit ratio.
In output cache we did eviction from a single admin endpoint, but in a real system data changes through many paths: the admin panel, an import job, another service's event. In the next post we move to invalidation strategies: TTL, explicit removal, event-driven invalidation, versioned keys, and why "write to the database, then delete" can still leave wrong data behind.