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.