Q21 — Rate Limiting in .NET 7+ — Fixed Window, Sliding Window, Token Bucket
Level: Senior | Topic: Performance Optimization
Why Rate Limiting?
Rate limiting protects your API from overload — whether from abusive clients, misbehaving integrations, or traffic spikes. .NET 7 introduced first-class built-in rate limiting via Microsoft.AspNetCore.RateLimiting, with four algorithms available out of the box.
The Four Algorithms
- Fixed Window: Allows N requests per fixed time window. Simple but has a burst spike risk at the window boundary (N requests at the last second + N at the first second of the next window = 2N burst).
- Sliding Window: Divides the window into segments and tracks requests per segment — eliminates the boundary burst. Higher memory usage.
- Token Bucket: A bucket of tokens refills at a steady rate. Requests consume a token; if empty, the request is queued or rejected. Allows short bursts up to the bucket size — the most natural fit for API rate limiting.
- Concurrency Limiter: Limits the number of concurrent in-flight requests (not rate). Ideal for expensive long-running operations like exports or report generation.
Code Example
// Program.cs — full rate limiting setup
services.AddRateLimiter(options =>
{
// Global — fixed window per IP
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "anon",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 1000,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
}));
// Per-authenticated-user — token bucket (allows short bursts)
options.AddPolicy("authenticated-api", ctx =>
RateLimitPartition.GetTokenBucketLimiter(
partitionKey: ctx.User.Identity?.Name ?? "anon",
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 100, // max burst
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
TokensPerPeriod = 10, // 10 req/sec average
AutoReplenishment = true,
QueueLimit = 5
}));
// Expensive endpoint — concurrency limit (max 2 concurrent per user)
options.AddPolicy("export", ctx =>
RateLimitPartition.GetConcurrencyLimiter(
partitionKey: ctx.User.Identity?.Name ?? "anon",
factory: _ => new ConcurrencyLimiterOptions
{
PermitLimit = 2,
QueueLimit = 1
}));
// 429 response with Retry-After header
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (ctx, ct) =>
{
if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
ctx.HttpContext.Response.Headers.RetryAfter =
retryAfter.TotalSeconds.ToString("0");
ctx.HttpContext.Response.StatusCode = 429;
await ctx.HttpContext.Response.WriteAsync("Rate limit exceeded. Retry later.", ct);
};
});
app.UseRateLimiter();
// Apply on controller/action
[HttpPost("export"), EnableRateLimiting("export")]
public async Task<IActionResult> ExportData(CancellationToken ct) { ... }
[HttpGet("products"), EnableRateLimiting("authenticated-api")]
public async Task<IActionResult> GetProducts(CancellationToken ct) { ... }
Senior Insight
Always return a Retry-After header on 429 responses — well-behaved clients will back off and retry automatically, reducing thundering herd. Partition by authenticated user ID (not IP) for API-key or JWT-authenticated endpoints — IP-based limiting is easily bypassed and unfairly penalises users behind NAT. Use the concurrency limiter for resource-intensive endpoints rather than a rate limiter — it directly prevents overloading the underlying service regardless of how fast requests arrive.