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

/products response

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

AddPolicy

Small number of known policies

Named policies

Shared default policy

Base policy

Tenant-specific policy selection

IOutputCachePolicyProvider

External policy configuration

IOutputCachePolicyProvider

Database-driven policy selection

IOutputCachePolicyProvider

Simple endpoint caching

CacheOutput

Complex custom cache behavior

IOutputCachePolicy

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

  1. Use standard named policies when caching requirements are static.

  2. Use IOutputCachePolicyProvider when policy resolution genuinely needs to be dynamic.

  3. Keep policy lookup fast and predictable.

  4. Avoid performing remote calls for every policy resolution.

  5. Separate policy selection from policy behavior.

  6. Define cache variation explicitly.

  7. Treat tenant boundaries as part of cache-key design.

  8. Never cache personalized responses without a deliberate isolation strategy.

  9. Keep cache durations appropriate for the freshness requirements of the data.

  10. Test cache misses, hits, expiration, and invalidation.

  11. Monitor cache size and entry growth.

  12. 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.