Overview
Everything is faster when you’re the only one using it. In the local environment, my API is a masterpiece of efficiency, a Ferrari on a private track. But once QA starts load testing, that masterpiece turns into a crime scene.
Under actual load, your endpoint starts wheezing because it’s doing the same heavy lifting for the 10,000th time. Whether it’s an expensive SQL join, a complex LINQ query from hell, or a Sitecore item resolution that’s trying to solve the secrets of the universe, re-calculating that same JSON response over and over is just… expensive.
Despite your manager begging you to hit up your infrastructure team and demand 32 more cores be thrown at the problem, let’s try a more reasonable approach. Let’s talk about Output Caching in .NET.
Why not just use [ResponseCache]?
Before we dive into the code, I already hear you, you’re saying “Damien, I already have [ResponseCache] on my controllers. Isn’t that enough? Maybe 32 cores would solve it. I only write perfect code after all.”
In short: No.
Here is the real breakdown of why they are different:
- Response Caching is basically you telling the user’s browser: “Hey, don’t ask me for this again for 60 seconds.” Much like speed limits in Texas, It’s a suggestion. If the user hits
F5or “Hard Refresh,” the browser ignores you, hits your server anyway, and subsequentially your database.
- Output Caching is the server taking a literal snapshot of the response. When a request comes in, the .NET middleware checks its own memory (or Redis). If it has the answer, it hands it back instantly. Your controller code – and your expensive database queries – never even run. I know you like the sound of that, I sure do.
One is a “Please don’t call me” note (Response Caching); the other is a “I’m not even going to pick up the phone” gatekeeper (Output Caching).
The Implementation Checklist
Before we start throwing code at the wall, let’s set the stage. I’m writing this from the perspective of a Web API project, so if you’re working on a legacy MVC monolith, you might need to squint a little to translate.
The Prerequisites:
- You are on .NET 8 or 9. (If you’re still on .NET 5 or framework, Godspeed to you.).
- You have a basic understanding of Middleware order.
- You’re tired of CPU alerts
That out of the way, getting this running requires three main components:
1. Register the Middleware
First, we need to tell the .NET builder that we intend to use Output Caching. This happens in your Program.cs.
var builder = WebApplication.CreateBuilder(args);
// Add the service with a default global policy
builder.Services.AddOutputCache(options =>
{
// By default, let's keep it simple: 5-minute expiration
options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(5)));
});
var app = builder.Build();
app.UseOutputCache(); // Place this after UseRouting and before UseEndpoints
Pro-tip on Middleware: Order matters. If you put
UseOutputCacheafter your endpoints, it will do exactly nothing. It needs to sit early in the pipeline so it can intercept the request before your controllers even wake up.
2. Configure the Endpoint
Once the middleware is in place, you have to decide where to apply the magic. You can go with a global policy while testing or if it’s just applicable, but in production or complex sites, we usually need more surgical control.
For those data-heavy endpoints, you can apply caching directly to the route as needed:
app.MapGet("/api/locations/search", async (string query, ISolrService solr) =>
{
// Imagine this is hitting a complex SOLR index with spatial search
return await solr.SearchAsync(query);
})
.CacheOutput(policy => policy
.VaryByQueryKeys("query") // Cache different results for "Florida" vs "Georgia"
.Expire(TimeSpan.FromMinutes(10))
.Tag("search_results")
);
VaryBy should be your new friend here. If you just hit .CacheOutput() and call it a day, the first person who searches for locations in Florida will have their results cached for everyone. The next person searching for Florida could see results for Georgia. Not ideal.
Using VaryByQueryKeys (or VaryByHeader even) ensures that the cache is smart enough to partition the data. It’s the difference between a high-performance API and a “why is the search broken?” support ticket at 3:00 AM.
Sitecore Note: If you’re building Sitecore-backed APIs, this is where you’d also vary by your language headers or site context. It ensures that your French users aren’t accidentally served the English cached fragment.
3. Cache Tags
One reason I reach for Output Caching over standard response caching is the ability to use Tags.
If you’re working in Sitecore or a high-frequency DB environment, TTL is important. If you update a specific item, you don’t want to wait 5 minutes for the cache to expire while your users see stale data. Conversely, you don’t want to purge your entire cache just because one record changed.
By utilizing tags, you can evict specific datasets immediately without touching the rest of your cached responses:
// Inject IOutputCacheStore to manually clear the cache
app.MapPost("/api/locations/update", async (int id, IOutputCacheStore cache) =>
{
// Logic to update your data...
// Evict everything tagged with "locations_tag"
await cache.EvictByTagAsync("locations_tag", default);
return Results.Ok();
});
Note: This is great for Sitecore integrations. Instead of a blanket “clear all” on publish, you can set up publish pipelines to clear specific Cache Tags by template. When a “News” item is published, you only evict the
news_api_tag, leaving your expensive “locations_tag” cache completely untouched and fast.
Why This Matters for Production
When managing SOLR instances or Sitecore environments, memory management is key. Output Caching allows you to:
- Reduce SOLR Pressure: Cache the search results at the API level, couples well with the SOLR caching OOB features, especially in high pressure websites.
- Vary by Context: Cache different versions of the same endpoint based on headers or query strings.
- Resource Efficiency: It’s much cheaper to serve a byte array from RAM than to build an object graph from a database 10,000 times.
Summary Checklist
- Register: Add
AddOutputCacheto your services inProgram.cs. - Order Matters: Ensure
UseOutputCacheis placed correctly in the middleware pipeline (after routing, before endpoints). - Partition your Data: Use
VaryByQueryKeysorVaryByHeaderto ensure users aren’t seeing each other’s cached data. - Stay Surgical: Implement
EvictByTagAsyncin your update or publish logic to keep data fresh without nuking the entire cache.
Final Thoughts
This should be more than enough to get you started with Output Caching. I’d advise at least running a load test in your QA environment to see the immediate benefits, the latency drop alone is usually enough to justify the effort.
With a proper implementation, you can build a highly extensible system where adding new tags becomes second nature. Use the tags, keep your data fresh, and let those 32 extra cores stay in the catalog.

Leave a comment