
Most ASP.NET Core rate-limiting examples stop after registering a fixed window. That is enough to demonstrate the middleware, but it is not enough for a production policy.
A production design should not start with an algorithm. It starts with the resource that fails first: database connections, CPU, memory, a downstream API or a commercial quota. Only then can a permit and its sharing boundary be defined.
A practical baseline for a public API is simple:
- The edge blocks volumetric and obviously malicious traffic.
- A local concurrency limit protects each application instance.
- Authenticated tenants or clients receive their own time-based partition.
- Expensive endpoints get a stricter policy.
- HTTP queues stay disabled until measurements justify them.
This is admission control, not DDoS protection and not billing. The middleware runs after traffic has reached Kestrel. It is excellent at rejecting work before the endpoint executes, but it does not replace a CDN, WAF, request-size limits, authentication lockout or durable quota accounting.
Release status: The .NET 11 behavior in this article reflects Preview 7 from August 2026. .NET 11 corrects
FixedWindowRateLimiterRetryAftermetadata so that it points to the next window boundary. Preview behavior still needs verification against the final release.
A request rate is not a capacity limit
A cached lookup that finishes in 2 milliseconds and a report that holds a database connection for 20 seconds are both one HTTP request. A limit of 100 requests per minute therefore says very little about the pressure each endpoint creates.
The design separates two questions:
- How much work may a client submit over time? A fixed window, sliding window or token bucket answers this.
- How much work may execute at once? A concurrency limiter answers this.
A time-based limit alone can admit too many slow requests. A concurrency limit alone can admit thousands of fast requests per second because every completed request immediately returns its permit. Production APIs commonly need both.
The built-in middleware acquires one permit per HTTP request. It cannot express that an export costs 50 times as much as a cached read. Separate endpoint classes suit coarse cost differences, while weighted commercial quotas need a dedicated accounting system. Billing should never depend on an in-memory middleware counter.
Choosing the limiter
| Limiter | Typical use | Important drawback |
|---|---|---|
| Fixed window | Simple quotas such as 600 requests per minute | Nearly twice the quota can pass around a window boundary |
| Sliding window | User-facing quotas where boundary bursts are undesirable | Segments approximate a rolling interval |
| Token bucket | A controlled burst plus a sustained average | A large bucket can still overload a dependency |
| Concurrency | Expensive or slow work with a known parallelism ceiling | It does not limit throughput over time |
Fixed window
Fixed windows are cheap and easy to explain. They fit cases where exact rolling behavior is less important than a predictable quota. The boundary burst must fit into downstream capacity; otherwise the simplicity is not worth it.
The corrected .NET 11 RetryAfter value makes the rejection useful to clients because it now reflects the actual next reset. It does not change the burst characteristic.
Sliding window
The built-in sliding window divides time into segments and carries recent usage into the next segment. More segments improve precision and add bookkeeping. It is a good default when users should not gain a large burst merely because a clock crossed a minute boundary.
Token bucket
The token limit is the burst budget. The replenishment rate is sustained throughput. Both require deliberate sizing; a bucket based only on the desired average usually permits a much larger spike than the protected service can absorb.
Concurrency
Concurrency is the most direct guard for database connections, CPU-heavy transformations, large uploads, report generation and fan-out calls. The permit count should come from load tests and dependency limits, not from the number of CPU cores.
The partition key is a security boundary
PartitionedRateLimiter<TResource> gives every key its own limiter. A good key creates fairness. A bad key either groups unrelated clients together or lets an attacker create unlimited limiter state.
Authenticated identifiers are preferable in this order:
- Tenant ID for multitenant APIs.
- OAuth client ID for machine-to-machine traffic.
- Subject ID for per-user controls.
- Normalized remote IP address as an anonymous fallback.
Raw Authorization values, API keys, query strings, full URLs, user-agent strings and arbitrary headers never belong in a partition key. They are secret, attacker-controlled, high-cardinality or all three.
Partitioned limiters eventually evict idle instances, but eviction does not make unbounded keys harmless. A stream of unique values still creates allocation churn and memory pressure before cleanup catches up.
Authentication must run before a policy reads HttpContext.User. The same rule applies to proxy handling: UseForwardedHeaders must establish a trusted RemoteIpAddress before an IP-based policy runs. Parsing X-Forwarded-For inside the limiter gives callers control over the partition unless every preceding proxy is configured correctly.
Plan changes need one more decision. A limiter is cached for its partition, so changing standard from 100 to 200 permits does not necessarily rebuild existing instances immediately. A bounded policy version such as standard-v2 can enter the key when immediate migration matters. A timestamp would create unbounded partitions and is therefore the wrong version.
A layered ASP.NET Core configuration
The following setup combines a local concurrency ceiling, a per-client token bucket, a sustained quota and a named policy for expensive work. The numbers are examples. None of them is a useful production default without load-test data.
1using System.Globalization;
2using System.Net;
3using System.Threading.RateLimiting;
4using Microsoft.AspNetCore.Mvc;
5using Microsoft.AspNetCore.RateLimiting;
6using Microsoft.Net.Http.Headers;
7
8public static class RateLimitingRegistration
9{
10 public const string ExpensiveOperationPolicy = "expensive-operation";
11
12 public static IServiceCollection AddApiRateLimiting(
13 this IServiceCollection services)
14 {
15 services.AddProblemDetails();
16
17 services.AddRateLimiter((RateLimiterOptions options) =>
18 {
19 options.RejectionStatusCode =
20 StatusCodes.Status429TooManyRequests;
21
22 PartitionedRateLimiter<HttpContext> capacityLimiter =
23 PartitionedRateLimiter.Create<HttpContext, string>(
24 (HttpContext httpContext) =>
25 RateLimitPartition.GetConcurrencyLimiter(
26 partitionKey: "service-instance",
27 factory: (string partitionKey) =>
28 new ConcurrencyLimiterOptions
29 {
30 PermitLimit = 200,
31 QueueLimit = 0,
32 QueueProcessingOrder =
33 QueueProcessingOrder.OldestFirst
34 }));
35
36 PartitionedRateLimiter<HttpContext> burstLimiter =
37 PartitionedRateLimiter.Create<HttpContext, string>(
38 (HttpContext httpContext) =>
39 RateLimitPartition.GetTokenBucketLimiter(
40 partitionKey: GetClientPartition(httpContext),
41 factory: (string partitionKey) =>
42 new TokenBucketRateLimiterOptions
43 {
44 TokenLimit = 20,
45 TokensPerPeriod = 10,
46 ReplenishmentPeriod =
47 TimeSpan.FromSeconds(1),
48 AutoReplenishment = true,
49 QueueLimit = 0,
50 QueueProcessingOrder =
51 QueueProcessingOrder.OldestFirst
52 }));
53
54 PartitionedRateLimiter<HttpContext> sustainedLimiter =
55 PartitionedRateLimiter.Create<HttpContext, string>(
56 (HttpContext httpContext) =>
57 RateLimitPartition.GetFixedWindowLimiter(
58 partitionKey: GetClientPartition(httpContext),
59 factory: (string partitionKey) =>
60 new FixedWindowRateLimiterOptions
61 {
62 PermitLimit = 600,
63 Window = TimeSpan.FromMinutes(1),
64 AutoReplenishment = true,
65 QueueLimit = 0,
66 QueueProcessingOrder =
67 QueueProcessingOrder.OldestFirst
68 }));
69
70 options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
71 capacityLimiter,
72 burstLimiter,
73 sustainedLimiter);
74
75 options.AddPolicy<string>(
76 ExpensiveOperationPolicy,
77 (HttpContext httpContext) =>
78 RateLimitPartition.GetConcurrencyLimiter(
79 partitionKey: GetClientPartition(httpContext),
80 factory: (string partitionKey) =>
81 new ConcurrencyLimiterOptions
82 {
83 PermitLimit = 2,
84 QueueLimit = 0,
85 QueueProcessingOrder =
86 QueueProcessingOrder.OldestFirst
87 }));
88
89 options.OnRejected = WriteRejectedResponseAsync;
90 });
91
92 return services;
93 }
94
95 private static string GetClientPartition(HttpContext httpContext)
96 {
97 string? tenantId =
98 httpContext.User.FindFirst("tenant_id")?.Value;
99
100 if (!string.IsNullOrWhiteSpace(tenantId))
101 {
102 return $"tenant:{tenantId}";
103 }
104
105 string? clientId =
106 httpContext.User.FindFirst("client_id")?.Value;
107
108 if (!string.IsNullOrWhiteSpace(clientId))
109 {
110 return $"client:{clientId}";
111 }
112
113 string? subjectId =
114 httpContext.User.FindFirst("sub")?.Value;
115
116 if (!string.IsNullOrWhiteSpace(subjectId))
117 {
118 return $"subject:{subjectId}";
119 }
120
121 IPAddress? remoteIpAddress =
122 httpContext.Connection.RemoteIpAddress;
123
124 if (remoteIpAddress is null)
125 {
126 return "anonymous:unknown";
127 }
128
129 IPAddress normalizedAddress = remoteIpAddress.IsIPv4MappedToIPv6
130 ? remoteIpAddress.MapToIPv4()
131 : remoteIpAddress;
132
133 return $"anonymous:{normalizedAddress}";
134 }
135
136 private static async ValueTask WriteRejectedResponseAsync(
137 OnRejectedContext context,
138 CancellationToken cancellationToken)
139 {
140 HttpResponse response = context.HttpContext.Response;
141 response.StatusCode = StatusCodes.Status429TooManyRequests;
142 response.Headers[HeaderNames.CacheControl] = "no-store";
143
144 if (context.Lease.TryGetMetadata(
145 MetadataName.RetryAfter,
146 out TimeSpan retryAfter))
147 {
148 int retryAfterSeconds = Math.Max(
149 1,
150 (int)Math.Ceiling(retryAfter.TotalSeconds));
151
152 response.Headers[HeaderNames.RetryAfter] =
153 retryAfterSeconds.ToString(CultureInfo.InvariantCulture);
154 }
155
156 ProblemDetails problemDetails = new ProblemDetails
157 {
158 Type = "https://www.rfc-editor.org/rfc/rfc6585#section-4",
159 Title = "Request rate limit exceeded",
160 Status = StatusCodes.Status429TooManyRequests,
161 Detail = "The request cannot be processed within the current limit.",
162 Instance = context.HttpContext.Request.Path.Value
163 };
164
165 problemDetails.Extensions["traceId"] =
166 context.HttpContext.TraceIdentifier;
167
168 IProblemDetailsService problemDetailsService =
169 context.HttpContext.RequestServices
170 .GetRequiredService<IProblemDetailsService>();
171
172 ProblemDetailsContext problemContext = new ProblemDetailsContext
173 {
174 HttpContext = context.HttpContext,
175 ProblemDetails = problemDetails
176 };
177
178 bool written = await problemDetailsService.TryWriteAsync(problemContext);
179
180 if (!written)
181 {
182 await response.WriteAsJsonAsync(
183 problemDetails,
184 options: null,
185 contentType: "application/problem+json",
186 cancellationToken: cancellationToken);
187 }
188 }
189}Three details in this code matter more than the syntax.
First, the concurrency limiter is first in the chain. If a later limiter rejects, disposing the acquired concurrency lease returns its permit. A consumed token or window permit is not the same kind of reversible reservation. Chain order therefore changes accounting.
Second, the named GetFixedWindowLimiter, GetSlidingWindowLimiter and GetTokenBucketLimiter registrations manage replenishment through the surrounding partitioned limiter. Their helper callbacks normalize AutoReplenishment; setting it there does not change behavior. Directly constructed limiters keep the normal option semantics.
Third, the global chain mixes capacity and quota failures but has one global rejection callback. The sample returns 429 for all global rejections. This is acceptable only when the public contract intentionally treats every rejection as a client admission limit. When callers must distinguish exhausted quota from service saturation, separate enforcement boundaries or a custom policy with explicit rejection metadata are required.
Middleware order is part of the policy
The partition is only as trustworthy as the state available when the limiter runs. This example orders proxy handling, routing, authentication, authorization and then rate limiting. It assumes authentication schemes and authorization policies have already been registered. Rejected authentication and authorization requests therefore never reach this limiter; separate controls at the edge or before authentication must cover that traffic.
1using System.Net;
2using Microsoft.AspNetCore.HttpOverrides;
3
4WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
5
6string trustedProxyValue =
7 builder.Configuration["Networking:TrustedProxy"]
8 ?? throw new InvalidOperationException(
9 "Networking:TrustedProxy is required.");
10
11IPAddress trustedProxyAddress = IPAddress.Parse(trustedProxyValue);
12
13builder.Services.Configure<ForwardedHeadersOptions>(
14 (ForwardedHeadersOptions options) =>
15 {
16 options.ForwardedHeaders =
17 ForwardedHeaders.XForwardedFor |
18 ForwardedHeaders.XForwardedProto;
19 options.ForwardLimit = 1;
20 options.KnownProxies.Add(trustedProxyAddress);
21 });
22
23builder.Services.AddApiRateLimiting();
24
25WebApplication app = builder.Build();
26
27app.UseForwardedHeaders();
28app.UseRouting();
29app.UseAuthentication();
30app.UseAuthorization();
31app.UseRateLimiter();
32
33app.MapGet(
34 "/catalog",
35 (CatalogService service, CancellationToken cancellationToken) =>
36 service.GetAsync(cancellationToken))
37 .RequireAuthorization();
38
39app.MapPost(
40 "/reports",
41 (
42 ReportRequest request,
43 ReportService service,
44 CancellationToken cancellationToken) =>
45 service.CreateAsync(request, cancellationToken))
46 .RequireAuthorization()
47 .RequireRateLimiting(
48 RateLimitingRegistration.ExpensiveOperationPolicy);
49
50app.MapGet("/health/live", () => TypedResults.Ok())
51 .DisableRateLimiting();
52
53app.Run();DisableRateLimiting bypasses the endpoint policy and the global limiter. It is suitable only for a narrow liveness endpoint protected by infrastructure. Applying it to a normal route creates a hole through every global control.
The default forwarded-header limit processes one proxy hop. A topology with several proxies needs the correct ForwardLimit and an explicit list of trusted networks or proxies. Clearing that trust list just to make forwarded addresses work is equivalent to trusting the caller.
Global and endpoint policies run sequentially
The global limiter acquires first. An endpoint policy attached with RequireRateLimiting acquires second. The handler executes only after both grant a lease.
This has an accounting consequence: a request can consume a global time-based permit and still be rejected by the endpoint concurrency policy. Such a policy counts attempted API requests. A commercial quota that counts only completed business operations belongs in durable business logic with idempotency, not in middleware.
Named policies can implement IRateLimiterPolicy<TPartitionKey> and provide their own OnRejected callback. That is the clean option when one endpoint needs a different problem type or rejection status than the global policy.
HTTP queues should stay at zero
A limiter queue does not create capacity. It stores an admitted HttpContext while consuming memory, a connection and the caller’s latency budget.
Queue length is also a poor proxy for waiting time. Ten queued cache lookups may clear in milliseconds; ten queued reports may wait for minutes. A queue should be enabled only after measuring service-time distributions and setting a maximum acceptable queue delay.
OldestFirst gives FIFO-like behavior. NewestFirst can discard older queued acquisitions when fresh work arrives, which is useful for replaceable status updates but surprising for transactional requests.
Cancellation has a visible edge case. When a queued request is canceled before acquisition, the middleware records a canceled result and does not call OnRejected. The connection may already be gone, so a 429 body would have no recipient.
Work that may wait for a meaningful period belongs on a durable broker. The API can validate the request, enqueue a command, return 202 Accepted and let workers process it at a controlled rate. That survives process restarts; an in-memory HTTP queue does not.
429 is not 503
ASP.NET Core defaults RateLimiterOptions.RejectionStatusCode to 503 Service Unavailable. It should be set explicitly because the correct value depends on what the policy protects.
| Status | Meaning | Typical policy |
|---|---|---|
429 Too Many Requests | This caller or partition exhausted a request policy | Per-tenant or per-client quota |
503 Service Unavailable | The service cannot safely accept more work | Service-wide concurrency or load shedding |
The distinction affects retries, dashboards, SLOs and incident diagnosis. A tenant exhausting its quota should not make the service look unavailable. A saturated service should not pretend that one caller violated a commercial limit.
RFC 6585 says that 429 responses must not be cached. Cache-Control: no-store adds a defensive signal for shared caches and gateways, where an ambiguous rejection becomes difficult to diagnose.
Retry-After comes from the lease
Retry-After should be emitted only when the rejected lease provides MetadataName.RetryAfter.
1if (context.Lease.TryGetMetadata(
2 MetadataName.RetryAfter,
3 out TimeSpan retryAfter))
4{
5 int retryAfterSeconds = Math.Max(
6 1,
7 (int)Math.Ceiling(retryAfter.TotalSeconds));
8
9 context.HttpContext.Response.Headers[HeaderNames.RetryAfter] =
10 retryAfterSeconds.ToString(CultureInfo.InvariantCulture);
11}Rounding up prevents an early retry when less than one second remains. Delay seconds also avoid clock skew between client and server.
A concurrency limiter cannot predict when an active request will finish. The header should be omitted instead of inventing a delay. Clients still need bounded retries and jitter because thousands of callers retrying on the same reset boundary create the next burst together.
The common X-RateLimit-* fields are conventions with incompatible reset semantics. The newer RateLimit fields are still evolving through the IETF process. Retry-After remains the stable rejection signal; remaining capacity should be published only when the API owns a precise, tested definition for it.
Rejections use the normal error contract
A rate-limited response should look like every other API error. The sample uses RFC 9457 Problem Details with a stable type, status, request path and trace ID. For the complete error-contract design, see Problem Details in ASP.NET Core and .NET 11 .
Partition keys, claims, API keys, limiter internals, replica counts and exact capacity stay out of the response. They either disclose sensitive data or turn implementation details into a public contract.
IProblemDetailsService.TryWriteAsync can return false when no registered writer supports the negotiated response. This sample deliberately falls back to application/problem+json even for an incompatible Accept header. That policy and media type belong in an HTTP contract test because middleware rejection never reaches the endpoint handler.
In-memory limits stop at the process boundary
Every built-in ASP.NET Core limiter keeps its state in one process. Twenty replicas with a limit of 100 requests per minute can admit roughly 2,000 requests per minute when traffic is distributed evenly.
Sticky sessions do not turn that into a global quota. Deployments, failover, autoscaling and load-balancer changes move traffic and reset effective state.
One of three models should be chosen and documented explicitly:
| Model | Good fit | Cost |
|---|---|---|
| Gateway quota | One shared policy before load balancing | Depends on gateway features and availability |
| Distributed atomic state | Application-owned global or regional quota | Adds latency and a dependency to every decision |
| Per-replica allocation | Fast approximate capacity with a bounded replica count | Becomes conservative or unsafe as replica count changes |
A Redis implementation must make the decision atomically. GET, compare and SET is racy because several replicas can observe the same count and admit simultaneously. Server-side scripts, transactions or purpose-built atomic commands are required, together with expiry, clock ownership, regional consistency and hot-key handling.
The failure mode of the shared store is part of the policy. A commercial quota may fail open briefly and reconcile usage later. An account-abuse policy may fail closed. The local concurrency guard should remain in both cases because a slow quota store must not remove protection from the application instance.
Every published number should therefore include its scope: per process, pod, region, gateway or global. Without that scope, the number is incomplete.
Operational metrics
ASP.NET Core publishes rate-limiting metrics through the Microsoft.AspNetCore.RateLimiting meter.
| Instrument | Operational value |
|---|---|
aspnetcore.rate_limiting.active_request_leases | Current admitted concurrency |
aspnetcore.rate_limiting.request_lease.duration | How long work keeps permits |
aspnetcore.rate_limiting.queued_requests | Current queue pressure |
aspnetcore.rate_limiting.request.time_in_queue | Latency spent before endpoint execution |
aspnetcore.rate_limiting.requests | Acquired, rejected and canceled outcomes |
Policy and result attributes distinguish endpoint rejection, global rejection, acquisition and cancellation. They should not identify a tenant. Tenant IDs in metric dimensions create high cardinality and may disclose personal or commercial information.
A high rejection count is not automatically an incident; it can mean the limiter is protecting a healthy service. It should be correlated with p99 latency, dependency saturation, queue time and active leases. Rejections without saturation often point to an unfair partition or a quota that is simply too small.
Tests that catch policy mistakes
The partition function should be tested as pure logic first. Cases should cover tenant, client, subject, IPv4, IPv4 mapped into IPv6, a missing remote address and an unauthenticated request. A separate assertion ensures that credentials and arbitrary headers never become keys.
Algorithm tests run directly against the limiter and verify permits, cancellation, queue order and metadata. For concurrency, one request holds a permit behind a TaskCompletionSource while a second request attempts acquisition. Arbitrary delays make that test slower and less deterministic.
The HTTP contract test checks the behavior clients depend on:
1using System.Net;
2using System.Net.Http.Json;
3using Microsoft.AspNetCore.Mvc;
4using Microsoft.AspNetCore.Mvc.Testing;
5
6public sealed class RateLimitingContractTests :
7 IClassFixture<WebApplicationFactory<Program>>
8{
9 private readonly HttpClient _client;
10
11 public RateLimitingContractTests(
12 WebApplicationFactory<Program> applicationFactory)
13 {
14 _client = applicationFactory.CreateClient();
15 }
16
17 [Fact]
18 public async Task LimitedEndpoint_WhenQuotaIsExhausted_ReturnsProblemDetails()
19 {
20 using HttpResponseMessage firstResponse =
21 await _client.GetAsync("/test/rate-limited");
22 using HttpResponseMessage secondResponse =
23 await _client.GetAsync("/test/rate-limited");
24 using HttpResponseMessage rejectedResponse =
25 await _client.GetAsync("/test/rate-limited");
26
27 Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
28 Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
29 Assert.Equal(
30 HttpStatusCode.TooManyRequests,
31 rejectedResponse.StatusCode);
32 Assert.Equal(
33 "application/problem+json",
34 rejectedResponse.Content.Headers.ContentType?.MediaType);
35 Assert.True(
36 rejectedResponse.Headers.Contains("Retry-After"));
37
38 ProblemDetails? problemDetails =
39 await rejectedResponse.Content
40 .ReadFromJsonAsync<ProblemDetails>();
41
42 Assert.NotNull(problemDetails);
43 Assert.Equal(
44 StatusCodes.Status429TooManyRequests,
45 problemDetails.Status);
46 Assert.True(problemDetails.Extensions.ContainsKey("traceId"));
47 }
48}The test host uses a small deterministic quota and each fixture owns its limiter state. Shared partitions make parallel tests consume each other’s permits.
Load tests choose the real numbers. They should vary request duration, hot partitions, bursts near window boundaries, cancellation, replica count, dependency slowdown and distributed-store latency. The rejection path also needs load testing: synchronous logging or one exported trace per rejection can become more expensive than the work the limiter blocks.
Rollout sequence
- Measure request rates, concurrency, service times, dependency pressure and p99 latency by endpoint class.
- Name the resource and policy scope before choosing a number.
- Add metrics with a high initial limit and no queue.
- Lower the limit gradually while comparing rejections with actual saturation.
- Test noisy partitions, scale-out, deployment, failover and quota-store failure.
- Publish status codes,
Retry-Afterbehavior, retry expectations and scope as part of the API contract.
Dynamic configuration needs explicit invalidation or bounded policy versions because cached per-partition limiters do not rebuild themselves merely because an option changed.
Design rule
Rate limiting is useful when every permit has a precise meaning. Concurrency protects capacity, time-based limits protect fairness and durable accounting protects commercial quotas. Those concepts should not be combined merely because they all contain a number.
A production limit is never simply “100”. It is 100 requests over a defined interval, for a trusted partition, enforced at a named boundary, with a documented scope across replicas. Everything else is only a demo configuration.
Further reading
Related articles

- 12 min read
Problem Details in ASP.NET Core and .NET 11
builder.Services.AddProblemDetails() registers infrastructure. It does not create an error contract. An ASP.NET Core request can fail during …

- 10 min read
What Matters in ASP.NET Core 11
ASP.NET Core 11 has no single feature that fundamentally changes how web applications are built. That is not a criticism. Mature frameworks …

- 10 min read
Distroless, Chiseled or Alpine: Choosing a .NET Base Image
Distroless, Chiseled and Alpine are often compared as three equivalent Linux distributions. They are not. Alpine is a distribution built …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.
