ASP.NET Core Output Cache: Speed Up Your API with In-Memory and Redis
Learn ASP.NET Core output caching the easy way: cache whole API responses in memory or in Redis, set policies, vary by query, and clear with tags — with diagrams and code.
The tea stall and the price board
Imagine a busy tea stall near a railway station. Every customer who walks up asks the same thing: "Bhaiya, what are today's rates?" If the owner shouts the full price list from memory for every single person, he gets tired and the line grows long.
So he does something clever. He writes the prices once on a board and hangs it at the front. Now most customers just read the board and pay. The owner only rewrites the board when prices actually change — maybe once a day.
That board is output caching. In an API, the "spoken price list" is the full response your endpoint builds — the JSON, the status code, the headers. Output caching writes that whole answer on a board the first time, and serves the board to everyone after that, until you decide to rewrite it.
This is different from caching one piece of data (like a single product). Output caching saves the entire finished response. So the second request does almost no work — it does not touch your database, your services, or even your endpoint method. It just hands back the saved answer.
What output caching actually saves
When a normal request hits your API, a lot happens: routing, your endpoint runs, it calls the database, builds objects, serializes them to JSON, and writes the bytes out. Output caching records the final bytes and replays them next time.
The first request is the slow one. It misses the cache, does all the work, and stores the result. Every request after that — until the cache expires — is the fast path on the left. For data that is read far more often than it changes, this is a giant win.
Output caching vs response caching
People often mix these up. They sound similar but they are not the same thing.
| Feature | Response Caching (older) | Output Caching (newer) |
|---|---|---|
| Where it stores | Browser / proxy via HTTP headers | Your server (memory or Redis) |
| Who controls it | Clients, proxies — you only hint | You, fully, on the server |
| Vary the key | Limited, header-based | Flexible: query, route, custom |
| Clear by tag | No | Yes, with EvictByTagAsync |
| Works with auth/cookies | Tricky and often skipped | Configurable per policy |
| Available since | Early ASP.NET Core | .NET 7 and later |
For a backend API where you want real control, output caching is almost always the right pick. The rest of this article focuses on it.
Turning it on: the smallest possible setup
You need two lines and one attribute. First register the service, then add the middleware, then mark an endpoint.
var builder = WebApplication.CreateBuilder(args);
// 1. Register output caching (in-memory by default)
builder.Services.AddOutputCache();
var app = builder.Build();
// 2. Add the middleware to the pipeline.
// Must come AFTER UseRouting and AFTER UseCors (if you use them).
app.UseOutputCache();
// 3. Mark an endpoint as cacheable
app.MapGet("/time", () => DateTime.UtcNow.ToString("o"))
.CacheOutput();
app.Run();Call the /time endpoint twice quickly. The timestamp will be the same both times, because the second call returned the cached response instead of running the lambda again. That is output caching working.
A small but important rule: UseOutputCache() must come after UseRouting(), and after UseCors() if you use CORS. If you put it in the wrong order, the cache may not match requests correctly.
Output cache setup steps
Steps
AddOutputCache
register the service
UseOutputCache
add middleware after routing
CacheOutput
mark endpoints to cache
Only GET and HEAD, only 200 OK
Output caching has sensible safety rules built in, so you do not accidentally cache the wrong thing:
- It only caches
GETandHEADrequests. APOSTthat changes data is never cached. - It only caches responses with status code 200 OK.
- It does not cache responses that set cookies, and by default it skips requests that carry an
Authorizationheader, because those are usually per-user.
These defaults stop the most common mistakes. You can change them with a policy when you really need to, but the defaults keep beginners safe.
Naming a policy and setting expiration
Sprinkling settings on every endpoint gets messy. Instead, define named policies once and reuse them. A policy answers questions like "how long does this live?" and "what makes two requests count as the same?"
builder.Services.AddOutputCache(options =>
{
// A default policy applied to every CacheOutput() call with no name
options.AddBasePolicy(policy =>
policy.Expire(TimeSpan.FromSeconds(30)));
// A named policy for product listings
options.AddPolicy("Products", policy =>
policy.Expire(TimeSpan.FromMinutes(5))
.Tag("products"));
// A named policy that varies the cache by a query string value
options.AddPolicy("ByCategory", policy =>
policy.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("category")
.Tag("products"));
});Then attach a policy by name on the endpoint:
app.MapGet("/products", GetAllProducts)
.CacheOutput("Products");
app.MapGet("/products/search", SearchProducts)
.CacheOutput("ByCategory");Expire sets how long the saved response stays valid. Tag labels the entry so you can clear it later. SetVaryByQuery is explained next.
Varying the cache key: why /products?category=tea must differ
Here is a trap many people hit. By default, the cache key is mostly the path. So /products?category=tea and /products?category=coffee could be treated as the same request and return the same cached answer — which is wrong.
SetVaryByQuery("category") fixes this. It tells the cache: "include the category value in the key, so different categories get different boards."
You can vary by query value, by route value, by header, or by a custom rule. The golden rule: anything that changes the response must be part of the key. If you forget, one user will see another user's answer, or one category will show another category's data.
| You want to vary by | Method to call |
|---|---|
| A query string value | SetVaryByQuery("category") |
| A route value | SetVaryByRouteValue("id") |
| A request header | SetVaryByHeader("Accept-Language") |
| A custom rule (e.g. tenant) | VaryByValue(...) |
Clearing the cache when data changes
A cache that never updates is dangerous. If someone adds a new product, the cached /products board is now stale. We need a way to wipe it.
This is where tags shine. We tagged the products policy with "products". When data changes, we inject IOutputCacheStore and evict everything with that tag in one call.
app.MapPost("/products", async (
Product product,
IProductService service,
IOutputCacheStore cacheStore,
CancellationToken ct) =>
{
await service.AddAsync(product, ct);
// Remove every cached response tagged "products"
await cacheStore.EvictByTagAsync("products", ct);
return Results.Created($"/products/{product.Id}", product);
});Now the flow is clean: writes invalidate the tag, the next read misses the cache and rebuilds a fresh response, and everyone sees correct data again.
Write then evict then rebuild
Steps
POST product
data changes
EvictByTag
clear 'products' entries
Next GET misses
cache is empty
Rebuild + cache
fresh response saved
This is the part people forget. Caching is easy; knowing when to clear is the real skill. A simple habit: whenever an endpoint changes data, ask "which cached responses are now wrong?" and evict their tag.
One server is fine — until you have two
So far the cache lives in the server's memory. That is fast and free. But picture production: you run two or three copies of your API behind a load balancer for reliability.
Now each server has its own memory cache. Server A caches /products. The next request goes to Server B, which has an empty cache, so it does the slow work again. Worse, when you evict the products tag on Server A, Server B never hears about it and keeps serving stale data.
The fix is a shared cache that every server reads and writes. That is what Redis gives you.
Moving the cache to Redis
Redis is a fast, separate store that all your servers talk to. Install the package and change one registration line — everything else (policies, tags, attributes) stays exactly the same.
dotnet add package Microsoft.AspNetCore.OutputCaching.StackExchangeRedisbuilder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration =
builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MyApi"; // logical name to separate caches
});
// Your policies stay identical
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Products", policy =>
policy.Expire(TimeSpan.FromMinutes(5)).Tag("products"));
});Notice it is AddStackExchangeRedisOutputCache, not the general-purpose AddStackExchangeRedisCache. They are different methods for different jobs. The output cache version plugs Redis in as the storage behind output caching specifically.
Now every server shares one cache. A response cached by Server A is instantly available to Server B. And when you call EvictByTagAsync("products"), it clears the shared Redis entries, so all servers immediately stop serving the stale copy.
A useful note from the docs: do not try to bolt output caching onto a plain IDistributedCache. That interface lacks the atomic operations needed for tag-based eviction. The dedicated Redis output-cache provider exists exactly because of this, so use AddStackExchangeRedisOutputCache and you get tagging that works.
Memory vs Redis: which should you pick?
| Question | In-memory | Redis |
|---|---|---|
| How many servers? | One | Two or more |
| Speed | Fastest (local RAM) | Very fast (network hop) |
| Shared across servers? | No | Yes |
| Survives a restart? | No | Yes |
| Extra infrastructure? | None | A Redis server |
| Eviction reaches all nodes? | No | Yes |
Simple advice: start with in-memory while you have a single server and are learning. Move to Redis the moment you scale to more than one instance, or when you need the cache to survive restarts. Because only the registration line changes, this upgrade is almost painless later.
A realistic end-to-end example
Let us tie it together. A product API caches reads for five minutes, varies the search by category, and evicts on every write.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Products", p =>
p.Expire(TimeSpan.FromMinutes(5)).Tag("products"));
options.AddPolicy("ProductSearch", p =>
p.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("category")
.Tag("products"));
});
var app = builder.Build();
app.UseOutputCache();
app.MapGet("/products", (IProductService s, CancellationToken ct)
=> s.GetAllAsync(ct))
.CacheOutput("Products");
app.MapGet("/products/search", (string category, IProductService s, CancellationToken ct)
=> s.SearchAsync(category, ct))
.CacheOutput("ProductSearch");
app.MapPost("/products", async (
Product product, IProductService s,
IOutputCacheStore cache, CancellationToken ct) =>
{
await s.AddAsync(product, ct);
await cache.EvictByTagAsync("products", ct);
return Results.Created($"/products/{product.Id}", product);
});
app.Run();Reads are fast and shared (once you switch to Redis). Writes stay correct because they immediately clear the relevant tag. This pattern handles the large majority of real APIs.
What not to cache
Output caching is powerful, but it is not for everything. Skip it when:
- The data is personal to each user (their cart, their messages). Caching these can leak one user's data to another unless you carefully vary by user — usually more trouble than it is worth here.
- The data changes every second and must always be live (a stock ticker, a live score).
- The endpoint writes data. Only cache safe, read-only
GETrequests.
A good test: if two different users sending the same request should get the same answer, output caching fits beautifully. If they should get different answers, either vary the key carefully or do not cache it.
Common mistakes to avoid
- Forgetting to vary the key. If the response depends on a query value, header, or route value, that thing must be in the key, or you will serve the wrong answer.
- Caching but never evicting. Always pair a write endpoint with an
EvictByTagAsynccall for the tags it affects. - Wrong middleware order.
UseOutputCache()goes afterUseRoutingand afterUseCors. - Expiry too long. Long expiry means stale data lingers if you miss an eviction. Start short (seconds to a few minutes) and tune up.
- Using IDistributedCache for output caching. Use the dedicated
AddStackExchangeRedisOutputCacheprovider so tagging works.
Quick recap
- Output caching saves the whole HTTP response and replays it, so repeat requests skip your endpoint, services, and database entirely.
- Turn it on with
AddOutputCache(),UseOutputCache()(after routing and CORS), and.CacheOutput()on endpoints. - It safely caches only GET/HEAD requests that return 200 OK, and skips cookies and auth by default.
- Use named policies to set
Expiretime,Tagfor grouping, andSetVaryByQueryso different inputs get different cache entries. - Clear stale data by tagging entries and calling
EvictByTagAsyncfrom your write endpoints. - In-memory is perfect for a single server. Switch to Redis with
AddStackExchangeRedisOutputCachewhen you run multiple servers or need the cache to survive restarts and evict consistently. - Do not cache per-user data, fast-changing live data, or write endpoints.
References and further reading
- Output caching middleware in ASP.NET Core — Microsoft Learn
- ASP.NET Core output cache provider for Azure Cache for Redis — Microsoft Learn
- Distributed caching in ASP.NET Core — Microsoft Learn
- ASP.NET Core Output Cache with In-Memory and Redis — Anton DevTips
Related Posts
Caching in ASP.NET Core: Make Your App Fast (The Easy Way)
Learn caching in ASP.NET Core with simple examples. Understand in-memory cache, distributed Redis cache, HybridCache, and output cache, with diagrams, code, and clear advice on which to use and when.
HybridCache in ASP.NET Core: The New Caching Library Explained
Learn HybridCache in ASP.NET Core the simple way: two-level caching, stampede protection, tag invalidation, GetOrCreateAsync, and Redis setup with clear diagrams.
Rate Limiting in ASP.NET Core: A Simple, Complete Guide
Learn rate limiting in ASP.NET Core with simple examples. Understand fixed window, sliding window, token bucket, and concurrency limiters, with diagrams, code, and real-world advice on which to pick.
How to Implement Caching Strategies in .NET: A Beginner-Friendly Guide
Learn caching strategies in .NET with simple examples: in-memory cache, distributed Redis cache, and HybridCache, plus eviction, stampede protection, and tags.
How to Increase the Performance of Web APIs in .NET
A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.
Advanced Rate Limiting Use Cases in .NET: A Friendly Deep Dive
Go beyond the basics of ASP.NET Core rate limiting: per-user limits, chained limiters, friendly 429 responses, Redis for many servers, and tier-based rules.