Rate limiting is one of those features that usually works quietly in the background.
An API receives requests, the rate limiter counts them, and once the configured limit is reached, additional requests receive a 429 Too Many Requests response.
That part is straightforward.
The more interesting question is what the client should do next.
If the server returns only:
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:
10 requests
per
1 minute
A client can make the first 10 requests successfully.
The next request is rejected:
Request 11
|
v
429 Too Many Requests
A simple fixed-window policy can be configured in ASP.NET Core like this:
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:
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:
10 requests / minute
The client sends:
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:
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:
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:
Retry-After: 30
means approximately:
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:
Retry-After: 15
The client can then wait before retrying.
The Problem With Incorrect Retry Information
Imagine a fixed-window limiter:
Window:
10:00:00 → 10:01:00
The client makes its final allowed request at:
10:00:50
The next request is rejected.
At this point, the client only needs to wait until:
10:01:00
So the correct retry delay is roughly:
10 seconds
If the server incorrectly calculates the delay from some other point, it could tell the client:
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:
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter,
out var retryAfter))
{
// retryAfter contains the suggested delay
}
Then the value can be copied into the HTTP response:
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString(
CultureInfo.InvariantCulture);
This is the important connection:
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:
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:
10 requests
during:
1 minute
Requests beyond the limit are rejected.
Adding an OnRejected Callback
To customize the 429 response, configure OnRejected:
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:
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:
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:
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:
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:
context.HttpContext.Response.Headers["Retry-After"] = "60";
This is easy to understand, but it is not always correct.
Suppose your window is:
60 seconds
A request could be rejected immediately after the window starts or near the end of the window.
If you always return:
Retry-After: 60
then the client may wait too long.
For example:
Window starts
|
| 50 seconds
v
Request rejected
|
v
Only 10 seconds until next window
Returning:
Retry-After: 60
would unnecessarily delay the client.
Using the limiter's metadata is better:
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:
10:00:00 ─────────────── 10:01:00
Window 1
10:01:00 ─────────────── 10:02:00
Window 2
Suppose:
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:
MetadataName.RetryAfter
will always be available for every possible limiter configuration.
Always check:
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:
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:
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:
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:
Retry-After: 10
If all of them retry exactly 10 seconds later, the server may receive another large burst:
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:
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:
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:
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:
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:
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:
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:
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:
app.UseRateLimiter();
A global limiter is useful as a broad safety mechanism.
You can still apply more specific policies to sensitive endpoints.
For example:
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:
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:
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:
{
"error": "rate_limit_exceeded",
"message": "Too many requests.",
"retryAfterSeconds": 17
}
The response can contain both:
HTTP/1.1 429 Too Many Requests
Retry-After: 17
Content-Type: application/json
and:
{
"error": "rate_limit_exceeded",
"message": "Too many requests.",
"retryAfterSeconds": 17
}
For example:
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:
(int)retryAfter.TotalSeconds
truncates the decimal part.
For example:
10.8 seconds
becomes:
10
A client could retry slightly too early.
Using:
(int)Math.Ceiling(
retryAfter.TotalSeconds)
produces:
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:
curl -i https://localhost:5001/products
After the limit is reached, look for:
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:
Window = 30 seconds
Limit = 5
Test requests:
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:
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:
429 responses per endpoint
429 responses per client
429 responses per user
Average retry delay
Maximum retry delay
Rate-limit rejection percentage
For example:
/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:
0.5%
to:
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:
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:
RetryAfter = "60";
when the limiter can provide the actual retry interval.
Assuming RetryAfter Always Exists
Always use:
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:
429 Too Many Requests
status code.
Return Retry-After When Available
Use limiter metadata:
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:
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.
Join the conversation! Your thoughts help the community grow.