I "added caching" to a read-heavy endpoint, deployed it, and watched the database graph stay exactly where it was. The response had a nice Cache-Control: public, max-age=60 header on it. The browser was happy. The database was still answering every single call, because the thing I'd added only ever talked to the browser.
That's the trap I want to show here, with a counter instead of a hand-wave. I built two endpoints that do the same "expensive" work, one guarded by a cache header and one by ASP.NET Core's output caching, and I incremented a number inside each handler. The number only moves when the handler actually runs. So if caching is doing anything at all, the number stops climbing. Everything below is from running it on .NET 10, Release build, in a small Linux container. Not a benchmark lab — I just want to see which handler runs and how often.
Two endpoints that look identical
Here's the whole setup. Both return a running count and a timestamp.
int responseCacheHits = 0;
int outputCacheHits = 0;
// A: "cached" the way a lot of us reach for first.
app.MapGet("/report-headers", (HttpContext ctx) =>
{
var n = Interlocked.Increment(ref responseCacheHits);
ctx.Response.Headers.CacheControl = "public, max-age=60";
return Results.Ok(new { ranCount = n, at = DateTime.UtcNow.ToString("HH:mm:ss.fff") });
});
// B: output caching. Same work, one extra line of wiring.
app.MapGet("/report-output", (HttpContext ctx) =>
{
var n = Interlocked.Increment(ref outputCacheHits);
return Results.Ok(new { ranCount = n, at = DateTime.UtcNow.ToString("HH:mm:ss.fff") });
}).CacheOutput(p => p.Expire(TimeSpan.FromMinutes(1)).Tag("reports"));
The [ResponseCache] attribute you've probably used on MVC controllers does the same thing as endpoint A under the hood: it sets response headers. It doesn't, by itself, store anything on the server. The CacheOutput call on endpoint B is the one that keeps a copy of the response in memory and hands it back without invoking the handler.
Then I hit each one ten times with a plain HttpClient from the same process and printed the count.
What the counter said
== Cache-Control header, 10 client calls ==
/report-headers call 1: {"ranCount":1,"at":"07:17:53.631"}
/report-headers call 10: {"ranCount":10,"at":"07:17:53.675"}
handler ran 10 times
== OutputCache, 10 client calls ==
/report-output call 1: {"ranCount":1,"at":"07:17:53.681"}
/report-output call 10: {"ranCount":1,"at":"07:17:53.681"}
handler ran 1 times
Ten calls, ten runs on the header endpoint. The timestamp even ticks forward between the first and last call, so you can see each response was freshly built. On the output-cache endpoint, ten calls, one run, and the timestamp is frozen at .681 because every call after the first got the stored bytes.
The reason the header approach does nothing here is that Cache-Control is a message to whoever's holding the response: your browser, a CDN, a proxy. An HttpClient doesn't keep a cache unless you wire one up, so it cheerfully re-requests every time and the server cheerfully recomputes. Add the ResponseCaching middleware and the server will honor those headers, but only under a surprisingly narrow set of conditions — GET or HEAD, status 200, no Authorization header, no Set-Cookie, a public max-age, and more. Miss any one and you're back to running the handler. Output caching skips all of that negotiation. It caches on the server by default, keyed on the request, and you opt out of cases you don't want rather than fighting to opt in.
Proving the cache is really a copy
The part that makes output caching usable in the real world is that you can throw entries away on demand. I tagged the entry reports, and when the underlying data changes you evict by tag:
var cache = app.Services.GetRequiredService<IOutputCacheStore>();
await cache.EvictByTagAsync("reports", default);
== after EvictByTagAsync("reports") ==
/report-output call 1: {"ranCount":2,"at":"07:17:53.703"}
/report-output call 3: {"ranCount":2,"at":"07:17:53.703"}
handler ran 2 times total
The handler ran exactly once more (count went from 1 to 2), and then the next two calls shared that new copy. So the flow is: compute once, serve the stored bytes until something changes, drop the tag when it does, compute once more. That's the whole loop, and it's the piece [ResponseCache] can't give you, because you can't reach into a browser's cache and tell it to forget something.
When I wouldn't bother
Output caching is memory by default, and it caches the whole response. If your endpoint is per-user or returns anything tied to the caller's identity, the default policy will happily serve one user's data to the next person unless you vary by the right thing, so I don't put it on authenticated endpoints without thinking hard about SetVaryByHeader or a vary-by-query policy first. It also isn't a substitute for data caching like HybridCache: output caching stores the finished response, so if three endpoints reshape the same underlying query, caching the query result once is often the better lever. My rough rule is that output caching earns its place on anonymous, read-mostly endpoints where the response is identical across a lot of callers — a public listing, a status page, a report everyone sees the same version of.
And the honest footnote on these numbers: the frozen timestamp and the counter are the real evidence, not any speed figure. I didn't quote milliseconds because in a loopback call on one box the handler barely does anything. The point was never "it's X times faster" — it's that the work stopped happening. That's the number that shows up on your database graph at 2am.
Run it yourself
It's one file. Make a net10.0 project with <Project Sdk="Microsoft.NET.Sdk.Web">, then here's the wiring around the two endpoints above:
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.OutputCaching;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddOutputCache();
var app = builder.Build();
app.UseOutputCache();
// ... the two MapGet endpoints from above go here ...
await app.StartAsync();
var addr = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!.Addresses.First();
using var http = new HttpClient { BaseAddress = new Uri(addr) };
async Task Hammer(string path, int times)
{
for (int i = 0; i < times; i++)
await http.GetStringAsync(path);
}
await Hammer("/report-headers", 10);
Console.WriteLine($"header handler ran {responseCacheHits} times");
await Hammer("/report-output", 10);
Console.WriteLine($"output handler ran {outputCacheHits} times");
await app.StopAsync();
Run it with ASPNETCORE_URLS=http://127.0.0.1:0 dotnet run -c Release and read the two counts. That's the experiment.
Have you been bitten by a [ResponseCache] that quietly did nothing on the server? I'd like to hear what tipped you off. Until then, still counting handler calls nobody asked me to count.
Top comments (0)