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"
};
For APIs with many query parameters, you need to decide exactly which parameters affect the response.
Using:
context.CacheVaryByRules.QueryKeys = "*";
is simple, but it can generate many cache entries.
A better policy often identifies the specific parameters that materially change the response.
Don't Cache Personalized Responses Accidentally
Output caching has important safety boundaries.
By default, output caching doesn't cache responses for authenticated requests, responses that set cookies, or non-GET/HEAD requests unless the policy is explicitly changed.
This default behavior is useful because personalized responses are often unsafe to share between requests.
For example:
/user/profile
may return:
{
"name": "Alice"
}
If Alice's response is cached and then served to Bob, the application has a serious data isolation problem.
Never override default caching behavior merely to increase the cache hit rate.
First establish that the response is safe to reuse.
Output Caching vs Distributed Caching
These concepts are related but not identical.
Feature | Output Caching | Distributed Data Caching |
|---|---|---|
Stores | Generated HTTP responses | Application data |
Main purpose | Avoid endpoint execution | Reuse application data |
Works at | HTTP/application response layer | Data/service layer |
Typical content | JSON, HTML, API responses | Objects, computed values |
Cache key | Request-derived | Application-defined |
Example |
| Product list from database |
A common architecture can use both.
For example:
HTTP request
|
v
Output cache
|
+---- Hit → response
|
+---- Miss
|
v
Application service
|
v
Data cache
|
+---- Hit
|
+---- Miss
|
v
Database
These layers solve different problems.
Policy Provider vs Policy Builder
ASP.NET Core already supports defining named policies with AddPolicy.
For example:
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Products", policy =>
{
policy.Expire(TimeSpan.FromMinutes(5));
});
});
This is still the right approach when the policy is static.
Use IOutputCachePolicyProvider when policy resolution itself needs to be dynamic.
Requirement | Recommended Approach |
|---|---|
Fixed expiration |
|
Small number of known policies | Named policies |
Shared default policy | Base policy |
Tenant-specific policy selection |
|
External policy configuration |
|
Database-driven policy selection |
|
Simple endpoint caching |
|
Complex custom cache behavior |
|
Don't introduce a custom provider when a normal named policy already solves the problem.
Common Mistakes
Creating a Dynamic Provider for Static Rules
If the application has only three fixed policies:
Short
Medium
Long
a normal named-policy configuration is simpler.
A custom provider adds complexity without providing meaningful value.
Using a Database on Every Policy Lookup
Avoid turning:
GetPolicyAsync("Products")
into a database query for every request.
Cache or locally maintain the policy configuration.
Forgetting Cache Variation
If query parameters affect the response, the cache key must distinguish them.
Ignoring Tenant Boundaries
A dynamic policy does not automatically isolate cache entries between tenants.
Caching Personalized Responses
Don't cache user-specific responses merely because the endpoint is expensive.
Creating Too Many Cache Entries
Varying by every possible query parameter, header, or tenant can produce excessive cache fragmentation.
Troubleshooting
The Provider Is Never Called
Check that:
Output caching is registered.
The provider is registered with dependency injection.
The endpoint actually selects a named output cache policy.
The policy name matches exactly.
For example:
app.MapGet("/products", GetProducts)
.CacheOutput("Products");
must match the provider's expected name:
Products
The Endpoint Always Executes
Inspect:
Cache policy eligibility
HTTP method
Response status
Authentication state
Cookies
Cache expiration
Cache variation rules
Don't assume a configured policy guarantees every response will be cached.
Different Query Results Share a Cache Entry
Check:
context.CacheVaryByRules.QueryKeys
Make sure every query parameter that changes the response is represented in the cache variation rules.
Tenant Data Is Mixed
Review the cache key and variation strategy.
A policy that changes expiration based on tenant isn't enough if the response itself is tenant-specific.
Cache Changes Don't Appear Immediately
Remember that already cached responses can remain available until they expire or are explicitly evicted.
If policy configuration changes dynamically, decide whether existing entries should remain valid or whether they need invalidation.
Testing Dynamic Policies
Don't test only the happy path.
A good test matrix includes:
Scenario | Expected Result |
|---|---|
Known policy | Correct policy returned |
Unknown policy | No invalid policy applied |
Disabled policy | Caching disabled or fallback used |
Configuration changes | New policy eventually used |
Different tenants | Correct cache variation |
Different query values | Separate cache entries where required |
Authenticated request | No accidental shared caching |
Cache hit | Endpoint isn't executed again |
Cache expiration | Fresh response is generated |
Configuration failure | Safe fallback behavior |
A dynamic caching system should be tested as infrastructure, not merely as an endpoint feature.
Best Practices
Use standard named policies when caching requirements are static.
Use
IOutputCachePolicyProviderwhen policy resolution genuinely needs to be dynamic.Keep policy lookup fast and predictable.
Avoid performing remote calls for every policy resolution.
Separate policy selection from policy behavior.
Define cache variation explicitly.
Treat tenant boundaries as part of cache-key design.
Never cache personalized responses without a deliberate isolation strategy.
Keep cache durations appropriate for the freshness requirements of the data.
Test cache misses, hits, expiration, and invalidation.
Monitor cache size and entry growth.
Use distributed storage when multiple application instances need a shared output cache.
Advantages
Dynamic Policy Selection
Policies can be resolved based on runtime requirements rather than being limited to a fixed startup configuration.
Better Multi-Tenant Architecture
Tenant-specific caching rules can be represented without creating a large collection of hardcoded policies.
External Configuration Support
Caching behavior can be controlled through configuration systems without embedding every rule in endpoint code.
Clear Separation
The provider selects a policy while the policy controls caching behavior.
Extensible Design
Applications can build policy-resolution systems around their own configuration and business requirements.
Disadvantages and Trade-Offs
More Complexity
A provider introduces another abstraction compared with ordinary named policies.
Configuration Dependencies
Dynamic policies can introduce dependencies on configuration stores or other services.
Cache-Key Design Becomes More Important
Dynamic expiration does not automatically solve response isolation.
More Difficult Debugging
When policy behavior depends on runtime configuration, developers need visibility into which policy was selected and why.
Potential Cache Fragmentation
Excessive variation can create many cache entries and reduce cache effectiveness.
Production-Oriented Example
A practical SaaS application can keep policy definitions in configuration while using a provider to resolve them.
public sealed class CachePolicyDefinition
{
public int DurationSeconds { get; init; }
public bool Enabled { get; init; }
public string[] QueryKeys { get; init; } = [];
}
The policy implementation can apply those rules:
public sealed class ConfiguredOutputCachePolicy
: IOutputCachePolicy
{
private readonly CachePolicyDefinition _definition;
public ConfiguredOutputCachePolicy(
CachePolicyDefinition definition)
{
_definition = definition;
}
public ValueTask CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
if (!_definition.Enabled)
{
context.EnableOutputCaching = false;
context.AllowCacheLookup = false;
context.AllowCacheStorage = false;
return ValueTask.CompletedTask;
}
context.EnableOutputCaching = true;
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
context.AllowLocking = true;
if (_definition.QueryKeys.Length > 0)
{
context.CacheVaryByRules.QueryKeys =
_definition.QueryKeys;
}
context.ResponseExpirationTimeSpan =
TimeSpan.FromSeconds(
_definition.DurationSeconds);
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
public ValueTask ServeResponseAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
}
The provider can then resolve it:
public sealed class ConfiguredOutputCachePolicyProvider
: IOutputCachePolicyProvider
{
private readonly IOptionsMonitor<CachePolicySettings> _settings;
public ConfiguredOutputCachePolicyProvider(
IOptionsMonitor<CachePolicySettings> settings)
{
_settings = settings;
}
public IReadOnlyList<IOutputCachePolicy> GetBasePolicies()
{
return [];
}
public ValueTask<IOutputCachePolicy?> GetPolicyAsync(
string policyName)
{
if (!_settings.CurrentValue.Policies.TryGetValue(
policyName,
out var definition))
{
return ValueTask.FromResult<IOutputCachePolicy?>(
null);
}
return ValueTask.FromResult<IOutputCachePolicy?>(
new ConfiguredOutputCachePolicy(definition));
}
}
Finally, register the provider:
builder.Services.Configure<CachePolicySettings>(
builder.Configuration.GetSection("OutputCaching"));
builder.Services.AddSingleton<
IOutputCachePolicyProvider,
ConfiguredOutputCachePolicyProvider>();
builder.Services.AddOutputCache();
Then use the policy from an endpoint:
app.MapGet("/products", GetProducts)
.CacheOutput("Products");
This structure keeps the endpoint clean.
The endpoint says:
Use the Products cache policy.
The provider determines:
What does Products mean right now?
The policy determines:
How should that caching behavior be applied?
That separation is the main architectural benefit of the new interface.
When You Should Not Use IOutputCachePolicyProvider
Not every application needs dynamic policy resolution.
If your application only needs:
Public API → 60 seconds
Catalog → 5 minutes
Documentation → 30 minutes
then normal named policies are easier:
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Catalog", policy =>
{
policy.Expire(TimeSpan.FromMinutes(5));
});
options.AddPolicy("Documentation", policy =>
{
policy.Expire(TimeSpan.FromMinutes(30));
});
});
A custom provider becomes worthwhile when the policy itself needs to be resolved dynamically.
The goal should be to add flexibility where it solves a real architectural problem, not to introduce another abstraction simply because the framework provides one.
Final Takeaway
ASP.NET Core output caching already provides a straightforward way to cache responses with named policies.
That remains the best choice when caching rules are stable and known ahead of time.
IOutputCachePolicyProvider in ASP.NET Core 11 addresses a different problem: dynamic policy resolution.
It allows an application to determine the base policies and resolve named policies at runtime:
public interface IOutputCachePolicyProvider
{
IReadOnlyList<IOutputCachePolicy> GetBasePolicies();
ValueTask<IOutputCachePolicy?> GetPolicyAsync(
string policyName);
}
This opens the door to scenarios such as tenant-specific cache rules, external configuration, feature-driven caching, and other runtime policy decisions.
The important architectural distinction is:
Policy Provider
|
| Selects
v
Cache Policy
|
| Defines
v
Caching behavior
|
v
Output Cache
For production systems, the biggest consideration isn't simply how long a response should be cached. It is whether the cached response is safe to reuse and whether the cache key correctly represents everything that makes that response different.
Use dynamic policies when you genuinely need dynamic policy selection. Keep static policies static. And always treat cache variation, tenant isolation, authentication, and data freshness as part of the caching design rather than as afterthoughts.

Join the conversation! Your thoughts help the community grow.