Output caching, HybridCache and rate limiting in ASP.NET Core solve three related production problems: serving repeated responses cheaply, avoiding expensive data fetches, and keeping any single client from overwhelming the service. All three ship with the framework, but each has subtle defaults, ordering rules and multi-instance caveats. This advanced guide covers output cache policies, tags and Redis storage, HybridCache's two-level design and stampede protection, the four rate limiting algorithms, per-user and per-IP partitions, correct 429 and Retry-After handling, and what changes when you run more than one instance.

What Are Output Caching, HybridCache and Rate Limiting?#

They operate at different layers of a request, and knowing the layer tells you when to reach for each one.

ToolLayerWhat it controlsShared across instances
Response cachingHTTP semanticsCache headers for browsers and proxies, plus an in-memory server cacheNot by itself
Output caching (.NET 7+)Endpoint responseWhole responses stored on the server under server-defined rulesWith the Redis store
HybridCache (package, 2025+)Data and objectsAny serializable value, in memory (L1) plus a distributed cache (L2)L2 yes, L1 no
Rate limiting (.NET 7+)Admission controlHow many requests a partition may start or run concurrentlyNo, counters are per process

Output caching skips the entire endpoint when a response is cached. HybridCache sits inside your code and caches the expensive part, such as a database query or downstream call, which is useful when responses are personalized but the underlying data is not. Rate limiting decides whether a request runs at all. Most production APIs use all three.

Getting Started: How They Fit in the Request Pipeline#

Middleware order matters for correctness, not just performance. Output caching must run after routing, CORS, authentication and authorization; placed earlier, it can serve content cached for the wrong audience. Rate limiting must run after routing when you use endpoint-specific policies. Forwarded headers must be processed first, or every client behind your load balancer appears to have the same IP address.

C#
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

// AddCors, AddAuthentication and AddAuthorization registrations omitted for brevity.
builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Catalog", policy => policy
        .Expire(TimeSpan.FromMinutes(10))
        .SetVaryByQuery("page", "sort")
        .Tag("catalog"));
});
builder.Services.AddHybridCache();
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; // default is 503
    options.AddFixedWindowLimiter("public", o =>
    {
        o.PermitLimit = 100;
        o.Window = TimeSpan.FromMinutes(1);
    });
});

var app = builder.Build();

app.UseForwardedHeaders();  // configured in the partitioning section below
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();       // after UseRouting, so endpoint policies apply
app.UseOutputCache();       // after CORS, authentication and authorization

app.MapGet("/catalog", GetCatalogAsync)
    .CacheOutput("Catalog")
    .RequireRateLimiting("public");

app.Run();

Placing UseRateLimiter before UseOutputCache means cache hits still consume permits. That is right when limits exist to stop scraping or abuse. If limits only protect backend resources, you can reverse the order so cached responses bypass the limiter entirely.

Response Caching vs Output Caching#

Response caching predates output caching and follows HTTP caching rules (RFC 9111). The [ResponseCache] attribute writes Cache-Control headers, and the optional middleware stores public responses in memory. Because it obeys the client, a browser refresh that sends Cache-Control: max-age=0 bypasses the server cache. It also refuses to cache requests with an Authorization header or responses that set cookies. That makes it a good tool for telling browsers and CDNs what to do, but a weak server-side cache.

Output caching, added in .NET 7, is controlled entirely by the server:

CapabilityResponse cachingOutput caching
Who decidesClient and server via HTTP headersServer configuration and policies
Client can bypassYes, no-cache is honoredNo
StorageIn-memory onlyIn-memory, Redis or a custom IOutputCacheStore
Programmatic evictionNoYes, by tag
Stampede protectionNoYes, resource locking by default
Revalidation (304 Not Modified)NoYes, with ETag and If-Modified-Since
Best useEmitting browser and CDN cache headersServer-side caching of API and page responses

The two can coexist: output caching for the server, and explicit Cache-Control headers for browsers and CDNs.

Output Caching Policies, Tags and Eviction#

Out of the box, output caching only caches GET and HEAD requests that return 200, and it skips authenticated requests and responses that set cookies. Entries expire after 60 seconds unless a policy says otherwise, the store is capped at 100 MB, and bodies over 64 MB are not cached. The cache key includes the scheme, host, port, path and query string, and policies narrow or extend it.

C#
using Microsoft.AspNetCore.OutputCaching;

// Share entries across instances (package Microsoft.AspNetCore.OutputCaching.StackExchangeRedis).
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("redis");
    options.InstanceName = "shop-oc:";
});

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Catalog", policy => policy
        .Expire(TimeSpan.FromMinutes(10))
        .SetVaryByQuery("page", "sort")      // ignore tracking parameters such as utm_*
        .Tag("catalog"));

    options.AddPolicy("Localized", policy => policy
        .Expire(TimeSpan.FromMinutes(5))
        .SetVaryByHeader("Accept-Language"));

    options.AddPolicy("NoLock", policy => policy.SetLocking(false));
});

var app = builder.Build();
app.UseOutputCache();

app.MapGet("/catalog", GetCatalogAsync).CacheOutput("Catalog");
app.MapGet("/catalog/{id:int}", GetItemAsync)
    .CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(30)).Tag("catalog"));

// After a write, evict every response tagged "catalog".
app.MapPut("/catalog/{id:int}", async (int id, ItemUpdate update, CatalogService catalog,
    IOutputCacheStore cache, CancellationToken ct) =>
{
    await catalog.UpdateAsync(id, update, ct);
    await cache.EvictByTagAsync("catalog", ct);
    return Results.NoContent();
}).RequireAuthorization("catalog-admin");

In controllers and Razor Pages, [OutputCache(PolicyName = "Catalog")] selects a policy. Razor Pages apply it to the page class. A base policy added with AddBasePolicy applies to every endpoint, which is convenient for tagging everything (for example, a tag-all tag for full purges) but risky if you only meant to cache a few endpoints.

Resource locking is the output cache's stampede protection: when an entry is missing, the first request renders the response and concurrent requests for the same key wait for it instead of all hitting the database. Disable it with SetLocking(false) only for endpoints where waiting would be worse than duplicate work. Revalidation is automatic: if a client sends If-None-Match or If-Modified-Since and the entry is fresh, the middleware answers 304 Not Modified without a body.

For storage, the in-memory default gives each instance its own cache, so an eviction on one server does not touch the others. AddStackExchangeRedisOutputCache shares entries and tag evictions across the fleet. Do not use a generic IDistributedCache for output caching; it lacks the atomic operations tagging needs. ASP.NET Core in .NET 11 adds IOutputCachePolicyProvider, which lets you resolve policies dynamically, for example from configuration or per tenant, instead of registering them all at startup.

HybridCache: Two-Level Caching with Stampede Protection#

HybridCache, from the Microsoft.Extensions.Caching.Hybrid package (generally available since March 2025 and now in the 10.x line), replaces most hand-written IMemoryCache and IDistributedCache code. One GetOrCreateAsync call checks the in-process L1 cache, then the distributed L2 cache if one is registered, and only then runs your factory. It serializes values for L2 (string and byte[] natively, System.Text.Json for everything else, with pluggable serializers such as protobuf) and, crucially, ensures that only one concurrent caller per key runs the factory while the others await the result.

C#
using Microsoft.Extensions.Caching.Hybrid;

// Any registered IDistributedCache becomes the L2 cache automatically.
builder.Services.AddStackExchangeRedisCache(options =>
    options.Configuration = builder.Configuration.GetConnectionString("redis"));

builder.Services.AddHybridCache(options =>
{
    options.MaximumPayloadBytes = 1024 * 1024; // default: larger values are logged, not cached
    options.MaximumKeyLength = 1024;           // default: longer keys bypass the cache
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(30),         // overall lifetime, including Redis
        LocalCacheExpiration = TimeSpan.FromMinutes(2) // in-memory lifetime
    };
});

A service then caches its expensive reads and invalidates them on writes:

C#
using System.ComponentModel;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;

public sealed class ProductCatalog(HybridCache cache, CatalogDbContext db)
{
    private static readonly HybridCacheEntryOptions Details = new()
    {
        Expiration = TimeSpan.FromHours(1),
        LocalCacheExpiration = TimeSpan.FromMinutes(5)
    };

    public ValueTask<ProductDto?> GetAsync(int id, CancellationToken ct = default) =>
        cache.GetOrCreateAsync(
            $"product:{id}",
            async token => await db.Products
                .Where(p => p.Id == id)
                .Select(p => new ProductDto(p.Id, p.Name, p.Price))
                .FirstOrDefaultAsync(token),
            Details,
            tags: ["products"],
            cancellationToken: ct);

    public async Task UpdatePriceAsync(int id, decimal price, CancellationToken ct = default)
    {
        await db.Products.Where(p => p.Id == id)
            .ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, price), ct);

        await cache.RemoveAsync($"product:{id}", ct); // this node's L1 plus the shared L2
    }

    public ValueTask InvalidateCatalogAsync(CancellationToken ct = default) =>
        cache.RemoveByTagAsync("products", ct);
}

// Sealed and immutable, so HybridCache may return the same L1 instance to every caller.
[ImmutableObject(true)]
public sealed record ProductDto(int Id, string Name, decimal Price);

Build keys from trusted identifiers with clear delimiters, and never from raw user input, which invites cache flooding and key-confusion attacks. Tag invalidation is logical: RemoveByTagAsync records "ignore entries with this tag created before now", and old values stay in storage until they expire. The reserved tag * invalidates everything. By default, HybridCache deserializes a fresh object for each caller for thread safety, and marking a type sealed with [ImmutableObject(true)] lets it reuse instances instead. Per-call HybridCacheEntryFlags, such as DisableDistributedCache or DisableLocalCacheWrite, handle special cases. .NET 11 adds a factory overload that receives a HybridCacheEntryContext, so a factory can adjust expiration based on what it fetched.

Two limitations matter in a web farm. Stampede protection is per HybridCache instance, so each server may still run the factory once. And removing a key clears the local L1 and the shared L2, but not the L1 caches on other servers, which keep serving stale data until LocalCacheExpiration elapses. Keep L1 lifetimes short for data that changes, or use an implementation with a backplane. FusionCache, for example, can register itself as the HybridCache implementation with .AsHybridCache() and adds Redis-based cross-node invalidation. The broader Caching in .NET guide compares these options with IMemoryCache and IDistributedCache.

Rate Limiting Middleware: Four Algorithms#

The rate limiting middleware (Microsoft.AspNetCore.RateLimiting, built on System.Threading.RateLimiting) lets you define named policies and attach them with RequireRateLimiting, [EnableRateLimiting] or [DisableRateLimiting]. In Razor Pages, apply the attributes to the page class, not to handlers. A GlobalLimiter runs for every request in addition to endpoint policies.

AlgorithmHow it countsStrengthWeakness
Fixed windowN permits per window, resetting at each boundarySimple and predictableAllows up to 2N around a boundary
Sliding windowWindow split into segments, with expired segments recycledSmooths boundary burstsSlightly more state
Token bucketBucket refills at a steady rate up to a capAllows controlled bursts, steady averageTwo parameters to tune
ConcurrencyN requests in flight at onceProtects scarce resources such as CPU or connectionsDoes not limit total volume
C#
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddFixedWindowLimiter("fixed", o =>
    {
        o.PermitLimit = 100;
        o.Window = TimeSpan.FromMinutes(1);
    });

    // Same budget, sliding in six 10-second segments.
    options.AddSlidingWindowLimiter("sliding", o =>
    {
        o.PermitLimit = 100;
        o.Window = TimeSpan.FromMinutes(1);
        o.SegmentsPerWindow = 6;
    });

    // Bursts up to 20, then 5 requests per second sustained.
    options.AddTokenBucketLimiter("burst", o =>
    {
        o.TokenLimit = 20;
        o.TokensPerPeriod = 5;
        o.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
        o.QueueLimit = 10;
        o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    });

    // At most 8 exports in flight, however fast they arrive.
    options.AddConcurrencyLimiter("exports", o =>
    {
        o.PermitLimit = 8;
        o.QueueLimit = 16;
    });
});

app.MapGet("/api/search", SearchAsync).RequireRateLimiting("sliding");
app.MapPost("/api/exports", StartExportAsync).RequireRateLimiting("exports");

Queues trade rejection for latency. A queued request waits for a permit instead of failing, which smooths short spikes, but a long queue just moves the overload into response times. Keep queues short on latency-sensitive endpoints.

Partitioned Limiters per User, API Key or IP#

A named policy created with AddFixedWindowLimiter is one shared bucket per endpoint, so one noisy client can exhaust it for everyone. Partitioned limiters give each key its own counters. PartitionedRateLimiter.CreateChained combines several global limiters, and a request must acquire a lease from each one in order.

C#
using System.Net;
using System.Security.Claims;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;

// Trust X-Forwarded-For only from your own proxies, or every client shares the proxy's IP.
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
    o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    o.KnownIPNetworks.Add(IPNetwork.Parse("10.20.0.0/16")); // .NET 10+ (KnownNetworks before)
});

builder.Services.AddRateLimiter(options =>
{
    options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
        // Per caller: signed-in users by subject, anonymous callers by IP.
        PartitionedRateLimiter.Create<HttpContext, string>(http =>
        {
            var userId = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
            if (userId is not null)
            {
                var premium = http.User.IsInRole("premium");
                return RateLimitPartition.GetTokenBucketLimiter($"user:{userId}", _ => new()
                {
                    TokenLimit = premium ? 200 : 50,
                    TokensPerPeriod = premium ? 100 : 20,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(10)
                });
            }

            var ip = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
            return RateLimitPartition.GetFixedWindowLimiter($"ip:{ip}", _ => new()
            {
                PermitLimit = 30,
                Window = TimeSpan.FromMinutes(1)
            });
        }),
        // Per instance: a safety valve on total concurrent work.
        PartitionedRateLimiter.Create<HttpContext, string>(_ =>
            RateLimitPartition.GetConcurrencyLimiter("instance", _ => new()
            {
                PermitLimit = 500,
                QueueLimit = 100
            })));
});

Partition keys deserve the same scrutiny as cache keys. Every distinct key creates and caches a limiter, so keys taken from unvalidated input, such as an arbitrary X-API-Key header, let an attacker exhaust memory by inventing keys. Validate API keys before using them as partitions. IP-based partitions are also weak: users behind corporate NAT share an address, and attackers can rotate addresses, so prefer authenticated identities and keep IP limits as a coarse outer layer. When you chain limiters, remember that time-based permits already taken are not returned if a later limiter rejects the request. Put the cheapest, most selective limiter first.

Handling Rejections: 429 and Retry-After#

The middleware rejects with 503 Service Unavailable by default, which tells clients and load balancers that the server is unhealthy. Always set RejectionStatusCode to 429 Too Many Requests, and tell well-behaved clients when to come back. Fixed window, sliding window and token bucket leases expose a RetryAfter value through lease metadata, while the concurrency limiter does not, because it has no time basis.

C#
using System.Globalization;
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.OnRejected = async (context, cancellationToken) =>
    {
        var http = context.HttpContext;

        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            http.Response.Headers.RetryAfter =
                ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
        }

        http.RequestServices.GetRequiredService<ILoggerFactory>()
            .CreateLogger("RateLimiting")
            .LogWarning("Rate limit exceeded for {Path}", http.Request.Path);

        await Results.Problem(
                statusCode: StatusCodes.Status429TooManyRequests,
                title: "Too many requests",
                detail: "Retry after the number of seconds in the Retry-After header.")
            .ExecuteAsync(http);
    };
});

RejectionStatusCode is applied before OnRejected runs, and anything OnRejected writes wins. In .NET 11, the fixed window limiter reports a RetryAfter value that accurately reflects the next window boundary, and fixes landed for token bucket replenishment and chained limiters, so apps that forward the metadata get correct intervals without code changes. The middleware also emits metrics under the Microsoft.AspNetCore.RateLimiting meter. Alert on rejection rates per policy with OpenTelemetry instead of discovering limits from customer complaints.

Distributed Rate Limiting Considerations#

The built-in limiters keep their counters in process memory. With five instances behind a load balancer, a "100 per minute" policy actually allows up to 500, and the exact figure depends on how the balancer spreads each client's requests. Decide whether that matters before building anything:

  • Approximate is often enough. For overload protection, per-instance limits divided by the instance count work well, especially with a concurrency limiter as a local safety valve. Remember to revisit the numbers when autoscaling changes the instance count.
  • Enforce at the edge. A gateway sees all traffic for a client. Azure API Management's rate-limit-by-key policy returns 429 with a Retry-After header, and a YARP gateway can run this same middleware in front of your services.
  • Use a shared store for exact global limits. The community RedisRateLimiting.AspNetCore package (not a Microsoft library) plugs Redis-backed limiters into the same AddRateLimiter options.
C#
using RedisRateLimiting.AspNetCore;
using StackExchange.Redis;

var redis = await ConnectionMultiplexer.ConnectAsync(
    builder.Configuration.GetConnectionString("redis")!);

builder.Services.AddRateLimiter(options =>
{
    options.AddRedisFixedWindowLimiter("partner-api", o =>
    {
        o.ConnectionMultiplexerFactory = () => redis;
        o.PermitLimit = 1_000;
        o.Window = TimeSpan.FromMinutes(1);
    });
});

A shared store adds a network round trip to every limited request and makes Redis part of your availability story. Decide deliberately whether a Redis outage should fail open, letting traffic through unlimited, or fail closed, rejecting everything, and test it. Hot partitions, such as one enormous tenant, concentrate load on a single key. For quotas measured in days or tied to billing, use a durable counter in a database rather than a rate limiter. The distributed rate limiter system design walkthrough explores these trade-offs in depth.

Best Practices#

  • Order middleware deliberately: forwarded headers first, then routing, CORS, authentication, authorization, rate limiting and output caching.
  • Cache at the highest safe layer. Use output caching for anonymous or shared responses and HybridCache for data behind personalized responses.
  • Tag everything you might need to purge, and evict by tag after writes instead of waiting for expiry.
  • Use Redis for output caching and HybridCache L2 when you run more than one instance, and keep HybridCache L1 lifetimes short for mutable data.
  • Always return 429 with Retry-After, and document limits for API consumers.
  • Partition by authenticated identity where possible, and validate any header-based key before trusting it.
  • Load test limits before production. Misconfigured limiters look exactly like outages.

Common Pitfalls#

  • Calling UseOutputCache before authentication and authorization, which can serve cached content to the wrong audience.
  • Expecting response caching to work in browsers, where refreshes send max-age=0 and bypass it.
  • Leaving the default 503 rejection status, which triggers load balancer failover and misleading alerts.
  • Partitioning by a spoofable or unbounded key, which turns the limiter into a memory exhaustion vector.
  • Forgetting forwarded headers, so every user shares the proxy's IP partition.
  • Assuming HybridCache invalidation reaches every server, when other nodes' L1 entries survive until they expire.
  • Caching huge payloads, which exceed MaximumPayloadBytes in HybridCache or MaximumBodySize in output caching and are silently skipped apart from a log entry.

Frequently Asked Questions#

Should I use output caching or HybridCache?#

Use output caching when many requests receive the identical response, such as catalog pages or public API lists, because it skips the endpoint entirely. Use HybridCache when responses differ per user but share expensive underlying data. Many apps use both: HybridCache inside the service layer and output caching on anonymous endpoints.

Does output caching work with authenticated users?#

Not by default, because responses to authenticated requests are never cached, which prevents data leaks. You can write a custom policy that caches per user by varying on a user identifier, but it is usually simpler and safer to cache the shared data with HybridCache and render personalized responses per request.

What is the difference between rate limiting and throttling with a queue?#

Rate limiting rejects requests over the limit, while queuing delays them until a permit frees up. ASP.NET Core supports both through QueueLimit: zero rejects immediately, and a positive value queues up to that many requests. Queue briefly to absorb spikes, but reject quickly when sustained demand exceeds capacity.

Is the built-in rate limiter enough to stop DDoS attacks?#

No. It protects an application from abusive clients and overload, but a distributed denial-of-service attack must be absorbed before it reaches your servers. Combine application limits with a web application firewall or DDoS protection service at the network edge.

How do I rate limit consistently across multiple instances?#

Either enforce limits in a gateway that sees all traffic, or use a shared counter store such as Redis through a distributed limiter implementation. If approximate limits are acceptable, divide the budget by the instance count and keep per-instance concurrency limits as a safety valve.

Summary#

  • Response caching emits HTTP cache semantics, while output caching is the server-controlled response cache with policies, tags, locking, revalidation and Redis storage.
  • HybridCache combines an in-memory L1 with a distributed L2 behind one GetOrCreateAsync call, with per-instance stampede protection and tag-based logical invalidation.
  • Fixed window, sliding window, token bucket and concurrency limiters cover different traffic shapes. Partition them by identity and chain them for layered protection.
  • Return 429 with Retry-After instead of the default 503, and watch rejection metrics.
  • In a web farm, share caches through Redis, keep L1 short-lived, and enforce exact limits at a gateway or in a shared store.

Further Reading#