One of those things that often operates silently in the background is rate limitation.
Requests are received by an API, the rate limiter counts them, and any further requests are met with a 429 Too Many Requests response. response if the configured limit is achieved.
That part is straightforward.
The more interesting question is what the client should do next.
If the server returns only:
1 2 | HTTP/1.1 429 Too Many Requests |
the client knows that it has been rate limited, but it does not know when it should try again.
This is where the Retry-After HTTP response header becomes useful.
ASP.NET Core 11 improves the rate-limiting middleware by making the RetryAfter metadata from FixedWindowRateLimiter accurately represent the next rate-limit window boundary. Applications that copy this metadata into the Retry-After response header can now tell clients when to retry more accurately.
This sounds like a small change, but it matters when APIs have automated clients, SDKs, background jobs, mobile applications, or other services that need to react correctly to rate limits.
What Is Rate Limiting?
Rate limiting controls how many requests a client can make during a particular period.
For example:
1 2 3 4 | 10 requests per 1 minute |
A client can make the first 10 requests successfully.
The next request is rejected:
1 2 3 4 5 | Request 11 | v 429 Too Many Requests |
A simple fixed-window policy can be configured in ASP.NET Core like this:
1 2 3 4 5 6 7 8 9 10 | builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter("api", limiterOptions => { limiterOptions.PermitLimit = 10; limiterOptions.Window = TimeSpan.FromMinutes(1); limiterOptions.QueueLimit = 0; }); }); |
Then apply the policy to an endpoint:
1 2 3 4 5 6 | app.MapGet("/products", () => { return Results.Ok("Products"); }) .RequireRateLimiting("api"); |
The application can now restrict how frequently that endpoint is called.
What Happens When the Limit Is Reached?
Suppose the policy allows:
1 2 | 10 requests / minute |
The client sends:
1 2 3 4 5 6 7 | Request 1 → 200 Request 2 → 200 Request 3 → 200 ... Request 10 → 200 Request 11 → 429 |
The client needs to know what to do with request 11.
A useful response is:
1 2 3 | HTTP/1.1 429 Too Many Requests Retry-After: 42 |
This tells the client to wait approximately 42 seconds before trying again.
Without the header:
1 2 | HTTP/1.1 429 Too Many Requests |
the client has to guess.
It might retry immediately.
That makes the situation worse.
Or it might wait for a fixed amount of time that is unnecessarily long.
The Retry-After header gives the server a way to communicate its expected retry timing.
What Does Retry-After Mean?
The HTTP Retry-After header can communicate how long a client should wait before making another request.
For example:
1 2 | Retry-After: 30 |
means approximately:
1 2 | Wait 30 seconds. |
It can also use an HTTP date, but rate-limiting middleware commonly uses a number of seconds.
For a rate-limited API, the relative form is convenient:
1 2 | Retry-After: 15 |
The client can then wait before retrying.
The Problem With Incorrect Retry Information
Imagine a fixed-window limiter:
1 2 3 | Window: 10:00:00 → 10:01:00 |
The client makes its final allowed request at:
1 2 | 10:00:50 |
The next request is rejected.
At this point, the client only needs to wait until:
1 2 | 10:01:00 |
So the correct retry delay is roughly:
1 2 | 10 seconds |
If the server incorrectly calculates the delay from some other point, it could tell the client:
1 2 | Retry-After: 60 |
The client would wait an unnecessary 50 seconds.
That is not just a cosmetic problem.
For high-volume applications, inaccurate retry information can reduce throughput significantly.
What Changed in ASP.NET Core 11?
ASP.NET Core 11 improves the FixedWindowRateLimiter behavior.
The limiter now reports a RetryAfter metadata value that accurately reflects the next window boundary.
That metadata is available through the rate-limit lease.
The application can retrieve it like this:
1 2 3 4 5 6 7 | if (context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter)) { // retryAfter contains the suggested delay } |
Then the value can be copied into the HTTP response:
1 2 3 4 | context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString( CultureInfo.InvariantCulture); |
This is the important connection:
1 2 3 4 5 6 7 8 9 10 11 | Rate limiter | v RetryAfter metadata | v HTTP Retry-After header | v Client knows when to retry |
Configuring the Rate Limiter
Start with a normal ASP.NET Core application:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | var builder = WebApplication.CreateBuilder(args); builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter( "api", limiterOptions => { limiterOptions.PermitLimit = 10; limiterOptions.Window = TimeSpan.FromMinutes(1); limiterOptions.QueueLimit = 0; }); }); var app = builder.Build(); app.UseRateLimiter(); app.MapGet("/products", () => { return Results.Ok("Products"); }) .RequireRateLimiting("api"); app.Run(); |
This creates a fixed-window limiter.
The application allows:
1 2 | 10 requests |
during:
1 2 | 1 minute |
Requests beyond the limit are rejected.
Adding an OnRejected Callback
To customize the 429 response, configure OnRejected:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | builder.Services.AddRateLimiter(options => { options.OnRejected = async ( context, cancellationToken) => { context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.HttpContext.Response.WriteAsync( "Too many requests.", cancellationToken); }; }); |
This works, but the response does not yet tell the client when to retry.
We can improve it by reading the limiter metadata.
Returning Retry-After
A better configuration is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | using System.Globalization; using System.Threading.RateLimiting; builder.Services.AddRateLimiter(options => { options.OnRejected = async ( context, cancellationToken) => { if (context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter)) { context.HttpContext.Response .Headers .RetryAfter = ((int)retryAfter.TotalSeconds) .ToString( CultureInfo.InvariantCulture); } context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.HttpContext.Response.WriteAsync( "Too many requests. Please try again later.", cancellationToken); }; }); |
This is the pattern that makes the .NET 11 improvement useful.
The rate limiter calculates the retry interval.
The application exposes it through the standard HTTP header.
Why Use MetadataName.RetryAfter?
The rate limiter returns information through RateLimitLease metadata.
Instead of manually calculating the retry time, use:
1 2 3 4 | context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter) |
This is preferable because the limiter knows its own scheduling behavior.
For a fixed-window limiter, the next available window can be calculated from the limiter’s actual state.
Your application does not need to duplicate that calculation.
That means less custom logic and fewer opportunities for timing bugs.
A Complete Example
Here is a small Minimal API example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | using System.Globalization; using System.Threading.RateLimiting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter( "products", limiterOptions => { limiterOptions.PermitLimit = 5; limiterOptions.Window = TimeSpan.FromSeconds(30); limiterOptions.QueueLimit = 0; }); options.OnRejected = async ( context, cancellationToken) => { if (context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter)) { context.HttpContext.Response .Headers .RetryAfter = ((int)retryAfter.TotalSeconds) .ToString( CultureInfo.InvariantCulture); } context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.HttpContext.Response.WriteAsync( "Rate limit exceeded.", cancellationToken); }; }); var app = builder.Build(); app.UseRateLimiter(); app.MapGet("/products", () => { return Results.Ok(new[] { "Laptop", "Monitor", "Keyboard" }); }) .RequireRateLimiting("products"); app.Run(); |
The client can receive something similar to:
1 2 3 | HTTP/1.1 429 Too Many Requests Retry-After: 17 |
The exact value depends on when the request is rejected relative to the next fixed-window boundary.
Why Hard-Coding Retry-After Is a Bad Idea
You may see examples like:
1 2 | context.HttpContext.Response.Headers["Retry-After"] = "60"; |
This is easy to understand, but it is not always correct.
Suppose your window is:
1 2 | 60 seconds |
A request could be rejected immediately after the window starts or near the end of the window.
If you always return:
1 2 | Retry-After: 60 |
then the client may wait too long.
For example:
1 2 3 4 5 6 7 8 9 | Window starts | | 50 seconds v Request rejected | v Only 10 seconds until next window |
Returning:
1 2 | Retry-After: 60 |
would unnecessarily delay the client.
Using the limiter’s metadata is better:
1 2 3 4 | context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter) |
The application uses the value calculated by the limiter instead of inventing its own value.
Fixed Window Rate Limiting
The fixed-window algorithm divides time into intervals.
For example:
1 2 3 4 5 6 | 10:00:00 ─────────────── 10:01:00 Window 1 10:01:00 ─────────────── 10:02:00 Window 2 |
Suppose:
1 2 3 | PermitLimit = 100 Window = 1 minute |
The first 100 requests in the window are accepted.
Additional requests are rejected until the next window begins.
This approach is simple and easy to reason about.
It is also where an accurate Retry-After value is particularly useful.
Retry-After and Different Rate Limiters
Not every rate-limiting algorithm can always provide an exact retry time.
For example, the ASP.NET Core documentation notes that RetryAfter can be used with algorithms such as fixed-window, token-bucket, and sliding-window limiters because those algorithms can estimate when permits will become available. A concurrency limiter does not have the same ability to predict when a permit will be released.
That distinction matters.
Do not assume:
1 2 | MetadataName.RetryAfter |
will always be available for every possible limiter configuration.
Always check:
1 2 3 4 5 6 7 | if (context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter)) { // Use retryAfter } |
rather than assuming the metadata exists.
Retry-After Is a Hint, Not a Guarantee
It is tempting to treat:
1 2 | Retry-After: 10 |
as an absolute promise.
It is better to treat it as guidance from the server.
Between the time the response is generated and the time the client retries, several things can happen.
For example:
- Network latency
- Clock differences
- Another request from the same client
- Another application instance
- Distributed rate limiting behavior
- Additional downstream limits
The client should therefore use the value sensibly rather than assuming the next request is guaranteed to succeed.
Client-Side Retry Logic
A well-behaved client can read the header:
1 2 3 4 5 6 7 8 9 10 11 12 13 | var response = await httpClient.GetAsync( "/products"); if (response.StatusCode == HttpStatusCode.TooManyRequests) { if (response.Headers.RetryAfter?.Delta is TimeSpan delay) { await Task.Delay(delay); } } |
The client waits before trying again.
For production systems, it is usually better to combine server-provided retry information with a retry policy and a maximum retry count.
For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 429 | +-- Read Retry-After | +-- Wait | +-- Retry | +-- Still 429? | +-- Yes → backoff / stop | +-- No → continue |
Never create an infinite retry loop.
Add Jitter for Large Client Fleets
Imagine 10,000 clients all receive:
1 2 | Retry-After: 10 |
If all of them retry exactly 10 seconds later, the server may receive another large burst:
1 2 3 4 5 6 7 8 9 | 429 responses | | 10 seconds v 10,000 retries | v Traffic spike |
This can create another rate-limit event.
A client can add a small amount of random jitter:
1 2 3 4 5 6 7 8 9 10 11 12 | Server says: Retry after 10 seconds Client A: 10.4 seconds Client B: 11.1 seconds Client C: 10.7 seconds |
This spreads the requests over a slightly larger interval.
The server still provides the primary retry guidance.
The client controls the final retry scheduling.
Retry-After With Queueing
Rate limiting can also be configured with a queue.
For example:
1 2 | limiterOptions.QueueLimit = 10; |
In this case, some requests may wait for permits rather than immediately receiving 429.
That changes the behavior.
A request that is queued is not the same as a request that has been rejected.
When designing an API, decide whether waiting is appropriate.
For short operations, a small queue may make sense.
For expensive endpoints, allowing many queued requests can increase memory usage and latency.
Do not use a large queue simply to avoid returning 429.
Rate Limiting by User
A global rate limit is easy to configure, but it is often not enough.
Suppose:
1 2 | Limit = 100 requests/minute |
If that is global, 100 requests from one aggressive client can consume the entire allowance for everyone.
Partitioned rate limiting can separate clients.
For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | builder.Services.AddRateLimiter(options => { options.AddPolicy( "user", httpContext => { var user = httpContext.User.Identity?.Name ?? "anonymous"; return RateLimitPartition .GetFixedWindowLimiter( user, _ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }); }); }); |
Then:
1 2 3 4 5 6 | app.MapGet("/profile", () => { return Results.Ok(); }) .RequireRateLimiting("user"); |
Each user gets an independent window.
Rate Limiting by IP Address
For public endpoints, IP-based partitioning can also be useful:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | options.AddPolicy( "ip", httpContext => { var ip = httpContext.Connection.RemoteIpAddress? .ToString() ?? "unknown"; return RateLimitPartition .GetFixedWindowLimiter( ip, _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }); }); |
However, IP addresses are not always a reliable representation of a single user.
Many users may share an IP through:
- Corporate networks
- Mobile carriers
- NAT
- Public Wi-Fi
- Proxies
If you are behind a reverse proxy, make sure forwarded headers are configured correctly before relying on client IP information.
Global Rate Limiting
A global limiter can protect the entire application:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | builder.Services.AddRateLimiter(options => { options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>( httpContext => { var key = httpContext.Connection .RemoteIpAddress? .ToString() ?? "unknown"; return RateLimitPartition .GetFixedWindowLimiter( key, _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }); }); }); |
Then:
1 2 | app.UseRateLimiter(); |
A global limiter is useful as a broad safety mechanism.
You can still apply more specific policies to sensitive endpoints.
For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | Global limit | v 100 requests/minute/IP Login endpoint | v 5 requests/minute/user Search endpoint | v 30 requests/minute/user |
This layered approach is often more practical than trying to use one limit everywhere.
Retry-After With Multiple Limiters
ASP.NET Core supports chained rate limiters.
For example:
1 2 3 4 5 6 | Global limiter + User limiter + Endpoint limiter |
A request can be rejected by one of the configured limiters.
The application should rely on the rejection context’s lease metadata rather than calculating the retry interval based on which limiter it assumes rejected the request.
That is another reason this pattern is useful:
1 2 3 4 5 6 7 | if (context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter)) { // Use the value supplied by the lease. } |
The callback does not need to duplicate limiter-specific timing logic.
Returning JSON Alongside Retry-After
An API may want to return a structured error body:
1 2 3 4 5 6 | { "error": "rate_limit_exceeded", "message": "Too many requests.", "retryAfterSeconds": 17 } |
The response can contain both:
1 2 3 4 | HTTP/1.1 429 Too Many Requests Retry-After: 17 Content-Type: application/json |
and:
1 2 3 4 5 6 | { "error": "rate_limit_exceeded", "message": "Too many requests.", "retryAfterSeconds": 17 } |
For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | options.OnRejected = async ( context, cancellationToken) => { var retryAfterSeconds = 0; if (context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter)) { retryAfterSeconds = (int)Math.Ceiling( retryAfter.TotalSeconds); context.HttpContext.Response .Headers .RetryAfter = retryAfterSeconds.ToString( CultureInfo.InvariantCulture); } context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.HttpContext.Response.WriteAsJsonAsync( new { error = "rate_limit_exceeded", message = "Too many requests.", retryAfterSeconds }, cancellationToken); }; |
The header remains useful for generic HTTP clients, while the JSON body provides application-specific information.
Use Ceiling When Converting TimeSpan
Be careful when converting a TimeSpan into seconds.
This:
1 2 | (int)retryAfter.TotalSeconds |
truncates the decimal part.
For example:
1 2 | 10.8 seconds |
becomes:
1 2 | 10 |
A client could retry slightly too early.
Using:
1 2 3 | (int)Math.Ceiling( retryAfter.TotalSeconds) |
produces:
1 2 | 11 |
That gives the client a safer whole-second value.
If your application has stricter timing requirements, consider how much precision the HTTP header and client actually need.
Testing the Retry-After Header
Do not just test that the endpoint returns 429.
Also verify the header.
For example:
1 2 | curl -i https://localhost:5001/products |
After the limit is reached, look for:
1 2 3 | HTTP/1.1 429 Too Many Requests Retry-After: 18 |
Then make another request after the expected interval.
You should see the request succeed when the new rate-limit window is available.
Testing Near the Window Boundary
This is particularly important with fixed-window rate limiting.
Suppose:
1 2 3 | Window = 30 seconds Limit = 5 |
Test requests:
1 2 3 4 5 6 | At second 1 At second 5 At second 10 At second 20 At second 29 |
Then send another request.
The returned retry value should reflect the remaining time until the next window.
For example:
1 2 3 4 5 | Window ends in 2 seconds Retry-After: 2 |
It should not simply return the full 30-second window.
This is the type of scenario where the .NET 11 improvement matters most.
Monitoring Rate-Limit Rejections
A production application should monitor rate-limit responses.
Useful metrics include:
1 2 3 4 5 6 7 | 429 responses per endpoint 429 responses per client 429 responses per user Average retry delay Maximum retry delay Rate-limit rejection percentage |
For example:
1 2 3 4 5 | /products Requests: 1,000,000 429: 8,500 Rate: 0.85% |
That tells you more than simply knowing that rate limiting exists.
If the rejection percentage suddenly jumps from:
1 2 | 0.5% |
to:
1 2 | 15% |
something may have changed.
Possibilities include:
- Client bug
- Traffic spike
- New integration
- Incorrect limit
- Retry storm
- Automated abuse
Rate limiting should therefore be observable, not just configured.
Do Not Use Rate Limiting as Your Only DDoS Protection
Application-level rate limiting is useful, but it is not a complete DDoS solution.
The request must already reach your infrastructure before ASP.NET Core can apply its rate limiter.
For serious attacks, protection may also need to exist at:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | Internet | v CDN / WAF | v Load Balancer | v Application | v ASP.NET Core Rate Limiter |
Each layer solves a different problem.
Application rate limiting is particularly useful because it understands application concepts such as:
- User
- API key
- Endpoint
- Tenant
- Operation type
Infrastructure-level protection can operate earlier in the request path.
Common Mistakes
Hard-Coding Retry-After
Avoid:
1 2 | RetryAfter = "60"; |
when the limiter can provide the actual retry interval.
Assuming RetryAfter Always Exists
Always use:
1 2 | TryGetMetadata(...) |
because not every limiter can predict when capacity will become available.
Retrying Immediately
A client that receives 429 should not immediately send another request.
That can create a retry storm.
Ignoring Jitter
Large numbers of clients retrying at exactly the same time can produce another traffic spike.
Returning 429 Without Guidance
A 429 without Retry-After leaves clients guessing.
Using the Same Limit Everywhere
Login, search, file upload, and health-check endpoints usually have different traffic patterns.
Creating Huge Queues
Queueing every request is not necessarily better than rejecting excess traffic.
Trusting IP Addresses Blindly
Proxies and shared networks can make IP-based limits behave differently from what you expect.
Production Recommendations
A practical rate-limiting setup should generally follow these principles:
Return 429
Use the standard:
1 2 | 429 Too Many Requests |
status code.
Return Retry-After When Available
Use limiter metadata:
1 2 3 4 | context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter) |
Let the Limiter Calculate Timing
Do not duplicate window calculations in application code.
Make Clients Retry Responsibly
Use:
- Retry-After
- Backoff
- Jitter
- Maximum retry count
Monitor Rejections
Track where and why requests are being rejected.
Test Under Realistic Load
Rate limiting changes application behavior under pressure.
Load test before production.
Summary
ASP.NET Core 11 improves an important part of rate limiting: knowing when a rejected request should be retried.
The FixedWindowRateLimiter now reports accurate RetryAfter metadata for the next window boundary. Applications can read that metadata from the rejected lease and expose it through the standard HTTP Retry-After response header.
The implementation is simple:
1 2 3 4 5 6 7 8 9 | if (context.Lease.TryGetMetadata( MetadataName.RetryAfter, out var retryAfter)) { context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)) .ToString(CultureInfo.InvariantCulture); } |
The bigger benefit is on the client side. Instead of guessing when to retry after receiving 429 Too Many Requests, the client gets useful timing information directly from the server.
It is still important to remember that Retry-After is not a complete retry strategy. Clients should combine it with sensible backoff, jitter, and a maximum retry count. Applications should also monitor rate-limit rejections and use infrastructure-level protection when dealing with larger traffic or abuse scenarios.
For ASP.NET Core applications that already use the rate-limiting middleware, this .NET 11 improvement makes 429 responses more useful without requiring complicated custom timing calculations.
Recommendation for ASP.NET 10.0 Hosting
A solid base for developing online services and applications is ASP.NET. Before creating an ASP.NET web application, you must be proficient in JavaScript, HTML, CSS, and C#. There are thousands of web hosting providers offering ASP.NET hosting on the market. However, there are relatively few web hosting providers that offer top-notch ASP.NET hosting.
ASP.NET is the best development language in Windows platform, which is released by Microsoft and widely used to build all types of dynamic Web sites and XML Web services. With this article, we’re going to help you to find the best ASP.NET Hosting solution in Europe based on reliability, features, price, performance and technical support. After we reviewed about 30+ ASP.NET hosting providers in Europe, our Best ASP.NET Hosting Award in Europe goes to HostForLIFE.eu, one of the fastest growing private companies and one of the most reliable hosting providers in Europe.
