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.
| Tool | Layer | What it controls | Shared across instances |
|---|---|---|---|
| Response caching | HTTP semantics | Cache headers for browsers and proxies, plus an in-memory server cache | Not by itself |
| Output caching (.NET 7+) | Endpoint response | Whole responses stored on the server under server-defined rules | With the Redis store |
| HybridCache (package, 2025+) | Data and objects | Any serializable value, in memory (L1) plus a distributed cache (L2) | L2 yes, L1 no |
| Rate limiting (.NET 7+) | Admission control | How many requests a partition may start or run concurrently | No, 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.
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:
| Capability | Response caching | Output caching |
|---|---|---|
| Who decides | Client and server via HTTP headers | Server configuration and policies |
| Client can bypass | Yes, no-cache is honored | No |
| Storage | In-memory only | In-memory, Redis or a custom IOutputCacheStore |
| Programmatic eviction | No | Yes, by tag |
| Stampede protection | No | Yes, resource locking by default |
Revalidation (304 Not Modified) | No | Yes, with ETag and If-Modified-Since |
| Best use | Emitting browser and CDN cache headers | Server-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.
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.
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:
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.
| Algorithm | How it counts | Strength | Weakness |
|---|---|---|---|
| Fixed window | N permits per window, resetting at each boundary | Simple and predictable | Allows up to 2N around a boundary |
| Sliding window | Window split into segments, with expired segments recycled | Smooths boundary bursts | Slightly more state |
| Token bucket | Bucket refills at a steady rate up to a cap | Allows controlled bursts, steady average | Two parameters to tune |
| Concurrency | N requests in flight at once | Protects scarce resources such as CPU or connections | Does not limit total volume |
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.
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.
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-keypolicy returns429with aRetry-Afterheader, 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.AspNetCorepackage (not a Microsoft library) plugs Redis-backed limiters into the sameAddRateLimiteroptions.
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
429withRetry-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
UseOutputCachebefore authentication and authorization, which can serve cached content to the wrong audience. - Expecting response caching to work in browsers, where refreshes send
max-age=0and bypass it. - Leaving the default
503rejection 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
MaximumPayloadBytesin HybridCache orMaximumBodySizein 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
GetOrCreateAsynccall, 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
429withRetry-Afterinstead of the default503, 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.