Output caching is one of the most effective ways to reduce repeated server-side work in an ASP.NET Core application.
When an endpoint produces the same response for many requests, generating that response repeatedly can waste CPU, database connections, network resources, and application time. Output caching allows ASP.NET Core to store the generated response and serve it again until the configured policy says that the cached response is no longer usable.
Traditional output caching works well when policies are known when the application starts.
For example:
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Products", policy =>
{
policy.Expire(TimeSpan.FromMinutes(5));
});
});
Then an endpoint can use the policy:
app.MapGet("/products", GetProducts)
.CacheOutput("Products");
This approach is simple and often sufficient.
Real production applications, however, sometimes need more dynamic decisions.
A multi-tenant application might give different tenants different cache durations. A feature-flag system might change caching behavior. A configuration service might determine whether a particular policy is enabled. An application might also need to resolve policies by name from an external configuration source.
ASP.NET Core 11 introduces IOutputCachePolicyProvider for these scenarios.
The interface provides a framework-level extension point for resolving base policies and named policies dynamically.
What Is Output Caching?
Output caching stores the generated response from an endpoint so that subsequent eligible requests can be served without executing the endpoint again.
Without output caching:
Request
|
v
Endpoint
|
v
Database
|
v
Business logic
|
v
Response
With output caching:
Request
|
v
Output Cache
|
+---- Cache hit ----> Response
|
+---- Cache miss
|
v
Endpoint
|
v
Database
|
v
Response
|
v
Store result
The benefit is greatest when an endpoint is relatively expensive to execute but its response can safely be reused for some period.
Typical examples include:
Product catalogs
Public configuration
Documentation pages
Frequently requested reports
Public API responses
Aggregated dashboard data
Read-heavy endpoints
Output caching should not be applied blindly. Responses containing user-specific or sensitive information require careful cache rules.
Why Static Cache Policies Are Sometimes Not Enough
A named policy is useful when the behavior is predictable:
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Short", policy =>
{
policy.Expire(TimeSpan.FromSeconds(30));
});
options.AddPolicy("Long", policy =>
{
policy.Expire(TimeSpan.FromMinutes(10));
});
});
The endpoint can select one of them:
app.MapGet("/products", GetProducts)
.CacheOutput("Long");
The limitation is that the policy name is resolved from the application's configured policies.
Consider a SaaS application with tenants such as:
Tenant A → 30-second cache
Tenant B → 5-minute cache
Tenant C → 15-minute cache
If these rules come from an external configuration system, hardcoding every combination into startup configuration becomes difficult to maintain.
This is where IOutputCachePolicyProvider becomes useful.
What Is IOutputCachePolicyProvider?
ASP.NET Core 11 introduces:
public interface IOutputCachePolicyProvider
{
IReadOnlyList<IOutputCachePolicy> GetBasePolicies();
ValueTask<IOutputCachePolicy?> GetPolicyAsync(
string policyName);
}
The provider has two responsibilities.
GetBasePolicies
This returns the base policies used by the output caching system.
IReadOnlyList<IOutputCachePolicy> GetBasePolicies();
Base policies are useful when a common caching policy should apply broadly.
GetPolicyAsync
This resolves a named policy:
ValueTask<IOutputCachePolicy?> GetPolicyAsync(
string policyName);
The policy can be resolved dynamically instead of requiring every possible policy to be statically registered.
This makes the provider suitable for scenarios where the policy definition comes from configuration or another application service.
Understanding IOutputCachePolicy
IOutputCachePolicyProvider resolves policies, but the actual caching behavior is represented by IOutputCachePolicy.
The interface contains three important operations:
public interface IOutputCachePolicy
{
ValueTask CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellationToken);
ValueTask ServeFromCacheAsync(
OutputCacheContext context,
CancellationToken cancellationToken);
ValueTask ServeResponseAsync(
OutputCacheContext context,
CancellationToken cancellationToken);
}
These methods participate at different points in the output-caching lifecycle.
Conceptually:
Incoming request
|
v
CacheRequestAsync
|
v
Check cache
|
+---- Hit ----> ServeFromCacheAsync
|
+---- Miss
|
v
Endpoint
|
v
ServeResponseAsync
|
v
Store response
This distinction is important when implementing custom policies.
A provider decides which policy should be used.
A policy decides how caching should behave.
A Simple Custom Policy
Before building a dynamic provider, it helps to understand a custom policy.
For example:
using Microsoft.AspNetCore.OutputCaching;
public sealed class PublicApiCachePolicy : IOutputCachePolicy
{
public ValueTask CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
context.EnableOutputCaching = true;
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
context.CacheVaryByRules.QueryKeys = "*";
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
public ValueTask ServeResponseAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
}
The exact policy should reflect the endpoint's requirements.
For example, varying by every query key may be appropriate for one endpoint but unnecessary for another.
Do not copy a cache policy into multiple applications without understanding what makes each response unique.
Creating a Dynamic Policy Provider
Suppose an application has a configuration model:
public sealed class CachePolicySettings
{
public Dictionary<string, CachePolicyDefinition> Policies { get; set; }
= new();
}
public sealed class CachePolicyDefinition
{
public int DurationSeconds { get; set; }
public bool Enabled { get; set; }
}
A provider can use these definitions to resolve named policies.
For example:
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Options;
public sealed class DynamicOutputCachePolicyProvider
: IOutputCachePolicyProvider
{
private readonly IOptionsMonitor<CachePolicySettings> _options;
public DynamicOutputCachePolicyProvider(
IOptionsMonitor<CachePolicySettings> options)
{
_options = options;
}
public IReadOnlyList<IOutputCachePolicy> GetBasePolicies()
{
return [];
}
public ValueTask<IOutputCachePolicy?> GetPolicyAsync(
string policyName)
{
var policies = _options.CurrentValue.Policies;
if (!policies.TryGetValue(
policyName,
out var definition))
{
return ValueTask.FromResult<IOutputCachePolicy?>(
null);
}
if (!definition.Enabled)
{
return ValueTask.FromResult<IOutputCachePolicy?>(
null);
}
IOutputCachePolicy policy =
new DynamicDurationPolicy(
TimeSpan.FromSeconds(
definition.DurationSeconds));
return ValueTask.FromResult<IOutputCachePolicy?>(
policy);
}
}
The provider itself does not implement the caching rules.
Its job is to find the requested policy and return an IOutputCachePolicy.
Building a Dynamic Duration Policy
The policy can then contain the actual caching behavior.
using Microsoft.AspNetCore.OutputCaching;
public sealed class DynamicDurationPolicy
: IOutputCachePolicy
{
private readonly TimeSpan _duration;
public DynamicDurationPolicy(TimeSpan duration)
{
_duration = duration;
}
public ValueTask CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
context.EnableOutputCaching = true;
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
context.AllowLocking = true;
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
context.ResponseExpirationTimeSpan = _duration;
return ValueTask.CompletedTask;
}
public ValueTask ServeResponseAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
context.ResponseExpirationTimeSpan = _duration;
return ValueTask.CompletedTask;
}
}
The important architectural point is that the policy is separate from the provider.
IOutputCachePolicyProvider
|
| resolves
v
IOutputCachePolicy
|
| controls
v
Output cache behavior
This separation makes dynamic policy selection easier to maintain.
Registering the Provider
The provider needs to be registered with dependency injection.
For example:
builder.Services.Configure<CachePolicySettings>(
builder.Configuration.GetSection("OutputCaching"));
builder.Services.AddSingleton<
IOutputCachePolicyProvider,
DynamicOutputCachePolicyProvider>();
builder.Services.AddOutputCache();
The configuration might look like:
{
"OutputCaching": {
"Policies": {
"Products": {
"DurationSeconds": 300,
"Enabled": true
},
"Categories": {
"DurationSeconds": 600,
"Enabled": true
}
}
}
}
Now the application can resolve policy names dynamically.
Applying a Named Policy
A Minimal API endpoint can select a named policy:
app.MapGet("/products", GetProducts)
.CacheOutput("Products");
The output caching infrastructure can ask the registered policy provider for the policy named:
Products
The provider can then return the appropriate policy based on current configuration.
This is the core value of IOutputCachePolicyProvider.
Dynamic Policies from External Configuration
One practical use case is external configuration.
For example:
Configuration service
|
v
Cache policy definitions
|
v
IOutputCachePolicyProvider
|
v
Output caching middleware
An application might store:
Products → 5 minutes
Categories → 10 minutes
News → 30 seconds
The provider translates those definitions into actual output cache policies.
This keeps cache policy selection separate from endpoint implementation.
However, there is an important consideration: external configuration can become an availability dependency.
If every cache policy lookup requires a network call, the caching system itself can become dependent on another service.
For production applications, policy configuration should generally be locally available, cached, or otherwise designed so that a temporary configuration-service outage doesn't make every request depend on that external service.
Tenant-Specific Caching
Multi-tenant applications are another strong use case.
Suppose:
Tenant A → Basic plan
Tenant B → Premium plan
Tenant C → Enterprise plan
The business rules might require different cache durations.
The provider could resolve a named policy based on tenant configuration.
However, there is a critical distinction:
Selecting a policy dynamically is not the same as varying the cache entry by tenant.
If the response itself contains tenant-specific data, the cache key must also distinguish tenants.
For example, this is dangerous:
/products
if the response depends on the current tenant but the cache entry doesn't vary by tenant.
The result could be one tenant receiving another tenant's cached response.
The policy and cache-key design must therefore be considered together.
Varying by Query Parameters
Consider:
/products?category=books
/products?category=hardware
These responses are different.
A policy may need to vary by query key:
context.CacheVaryByRules.QueryKeys = new[]
{
"category"
};

Join the conversation! Your thoughts help the community grow.