Tag-Based Cache Eviction with ASP.NET Core Output Cache
Author: Oğuzhan KARAGÜZEL ·

On This Page
Rendering a page once and keeping it, instead of reading it from the database and rebuilding it on every request, is the most effective way to speed up a website. The hard part isn't keeping it; it's knowing when to throw it away. In Elevare, the platform this site runs on, we took the easy route for a long time: whenever anything was saved in the CMS, the site's whole cache was cleared. In this article I explain why that is expensive and how we built tag-based eviction with ASP.NET Core's Output Cache, so that only the affected pages are cleared.
The problem: every save cooled down the whole site
Elevare consists of two applications: the CMS where content is edited, and the site visitors see. Both use the same database but run in separate processes. The site kept rendered pages in its cache for an hour. Whenever anything was saved in the CMS, the site's /api/cache/clear endpoint was called and the entire cache was cleared.
The result: fixing a typo in one article also dropped the homepage, every article and every project page from the cache. On top of that, the background job that rebuilds the sitemap every two hours triggered the same clear each time it ran. On a low-traffic site, that meant most visitors landed on a page whose cache had just been emptied. Measured locally, a cold page took about 600 ms to render, while a cached or warmed-up page took about 50 ms.
A quick look at ASP.NET Core Output Cache
Output Cache, introduced in .NET 7, is middleware that stores the entire response an endpoint produces. You define a policy, attach it to an endpoint and enable the middleware:
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Pages", policy => policy
.Expire(TimeSpan.FromHours(24))
.Tag("pages")
.SetVaryByQuery(Array.Empty<string>()));
});
var app = builder.Build();
app.UseRouting();
app.UseOutputCache();public class PageController : Controller
{
[OutputCache(PolicyName = "Pages")]
public async Task<IActionResult> Index(string slug) { /* ... */ }
}SetVaryByQuery(Array.Empty<string>()) is an important detail. By default the whole query string becomes part of the cache key, which means a separate cache entry for every link shared with ?utm_source=... or ?fbclid=.... If some parameters really do change the page's content (for us, the page number and search text of listing pages), add only those to the key.
The feature that matters most to us is tags. Each cache entry can carry one or more tags, and IOutputCacheStore.EvictByTagAsync removes every entry with a given tag in one go.
Designing the tags: what was the page built from?
Tags fixed at the policy level are not enough. What a page was built from is only known while it is being rendered. So for each request we use a small object where the services rendering the page record what they read:
public sealed class PageCacheDependencies
{
private readonly HashSet<string> _tags = new(StringComparer.Ordinal) { "pages" };
public IReadOnlyCollection<string> Tags => _tags;
public void Add(string tag) => _tags.Add(tag);
}
// Program.cs: one per request
builder.Services.AddScoped<PageCacheDependencies>();While the page renders, each service adds its own dependency: the page itself as page:42, a linked template it uses (header, footer) as tpl:3, pages named in the breadcrumb as info:11, and listing if it shows a page listing. Just before the response goes out, these tags are added to the cache entry:
private void TagCachedResponse()
{
if (HttpContext.Features.Get<IOutputCacheFeature>()?.Context is not { } cache)
return;
foreach (string tag in cacheDependencies.Tags)
cache.Tags.Add(tag);
}The tags we use and what they mean:
| Tag | On which entries? | Evicted when? |
|---|---|---|
pages | Every page, the 404, RSS and sitemaps | On any change that can't be classified |
page:{id} | The page itself | Its content or settings change |
info:{id} | Pages whose title or address appears on another page | Its title or address changes |
tpl:{id} | Pages using a linked template | The template is edited |
listing | Pages with a listing or previous/next links, RSS | A page is added, unpublished or retagged |
notfound | Cached 404 responses | Any change that may publish a page at that address |
The last row is easy to miss: if you also cache 404 responses briefly, the old 404 must go the moment a page is published at that address.
The CMS side: turning a change into tags
The most reliable place to decide which tags to evict is the change being written to the database itself. In the CMS, EF Core's ChangeTracker knows every row about to be saved and which of its columns changed. We wrote a classifier that walks these entries during SaveChanges and turns each into tags:
foreach (EntityEntry entry in changeTracker.Entries())
{
if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
continue;
switch (entry.Entity)
{
case PageContent content:
if (OnlyInvisibleColumns(entry)) // e.g. only the preview copy changed
continue;
change.AddTag($"page:{content.PageInfoId}");
change.AddTag("listing");
break;
case PageTemplate template:
change.AddTag($"tpl:{template.Id}");
break;
case UserNote or AuditLog:
break; // the site never reads these tables
default:
change.MarkEverything(); // unknown change: clear everything
break;
}
}The most important decision here is that the default is "clear everything". A table added later and never taught to the classifier falls back to the old, safe behaviour instead of a narrowed eviction. An improvement made for speed must not turn into a much worse bug, such as serving stale content. Narrowing is added deliberately, table by table.
Then there are invisible columns. The CMS writes audit fields such as "last updated" on every save, and the editor's unsaved preview copy lives in a separate column. If only those columns changed, nothing a visitor sees has changed, and no tag is evicted.
The site side: an endpoint that evicts by tag
Once the save completes, the CMS sends the collected tags to an endpoint on the site. Since the two applications are separate processes, this is an HTTP call, protected not by a user login but by a shared secret both sides know:
app.MapPost("/api/cache/evict", async (
HttpContext http, EvictRequest body, IOutputCacheStore outputCache,
PageCacheWarmer warmer, CancellationToken ct) =>
{
if (!InternalAdminAuthorization.IsAuthorized(http, clearSecret))
return Results.Unauthorized();
if (body.Tags is not { Count: > 0 and <= 2000 })
return Results.BadRequest();
foreach (string tag in body.Tags.Distinct(StringComparer.Ordinal))
await outputCache.EvictByTagAsync(tag, ct);
warmer.RequestWarmup();
return Results.NoContent();
});Mind one ordering subtlety: if pages are built from data held in other caches (the language list, site settings), clear those data caches first and the page cache last. In the opposite order, a request arriving in between could re-render a page from stale data and keep it cached for a whole day.
Warming the cache before visitors arrive
Targeted eviction reduces the number of dropped pages, but not to zero. The first visitor to open a dropped page still waits for a cold render. To remove that too, a background service (BackgroundService) inside the site requests the published pages itself after an eviction:
public sealed class PageCacheWarmer : BackgroundService
{
// A one-slot channel: requests made while a warm-up is pending are folded into it.
private readonly Channel<bool> _signal = Channel.CreateBounded<bool>(
new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
public void RequestWarmup() => _signal.Writer.TryWrite(true);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (await _signal.Reader.WaitToReadAsync(stoppingToken))
{
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); // fold a burst of evictions into one pass
_signal.Reader.TryRead(out _);
await WarmAsync(stoppingToken);
}
}
}WarmAsync reads the addresses of the published pages from the database and requests them from the same process over loopback, at most two at a time. Since pages still in the cache answer from the cache, "warming" the whole site after a narrow eviction really only re-renders the pages that were dropped.
Two details matter. First, the cache key and the absolute links on the page depend on the domain the visitor used. Even though warm-up requests go to 127.0.0.1, they must carry the real visitor's address in the Host and X-Forwarded-Proto headers; otherwise the cache fills with a copy nobody will ever request. Second, warm-up requests are marked with a special header so they aren't counted as visitors in analytics. The same warm-up also runs when the application starts, so the first visitors after a deploy don't hit cold pages either.
Conclusion: what did we gain?
After these changes:
- Saving an article drops only that article, the pages listing it and the RSS feed from the cache. Changes the site never reads, such as notes, tasks or user records, touch no page at all.
- Since changes reach the site the moment they are saved, pages can stay cached for 24 hours instead of one.
- Dropped pages are re-rendered in the background within seconds; visitors almost always get a cached page, ready in about 50 ms.
On a site running on several servers, the default in-memory store isn't enough; for all servers to see the same cache, Output Cache has to use a shared store such as Redis. The tag logic stays exactly the same. Elevare is open source; you can browse the full code on GitHub. Why the first requests after a deploy are slow is covered in ReadyToRun and Cold Starts.
