Rate Limiting and Throttling APIs in .NET
I didn't take rate limiting seriously until I watched a single misbehaving client script hammer an endpoint hard enough to degrade response times for every other consumer of that API. Nothing malicious, just a retry loop with no backoff on their end. That incident changed how I think about rate limiting: it's not primarily a security feature, it's a stability feature that happens to also help with security.
The built-in middleware makes this genuinely easy
.NET's rate limiting middleware, introduced in .NET 7, removed most of the excuses for skipping this. It's a few lines to get real protection:
csharp
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("standard", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueLimit = 0;
});
});
app.UseRateLimiter();
That's a fixed window limiter, and it's a reasonable default for most APIs. Its weakness is bursts at window boundaries, where a client could send 100 requests right at the end of one window and another 100 right at the start of the next.
Choosing the right algorithm for the actual problem
For APIs where burst behavior at window edges genuinely matters, sliding window or token bucket limiters handle it more gracefully:
csharp
options.AddTokenBucketLimiter("burst-friendly", opt =>
{
opt.TokenLimit = 100;
opt.TokensPerPeriod = 20;
opt.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
opt.QueueLimit = 5;
});
Token bucket allows short bursts while still enforcing an average rate over time, which fits real usage patterns better than a strict fixed window for most consumer-facing APIs. I pick the algorithm based on the actual traffic shape I'm protecting against, not by default habit.
Different limits for different endpoints, deliberately
Not every endpoint deserves the same limit. Authentication endpoints, as I've mentioned before, need tighter limits because they're a common target for credential stuffing. Expensive reporting endpoints that hit multiple downstream services need lower limits than a simple lookup endpoint. Treating every route identically usually means either being too permissive on the endpoints that matter most, or too restrictive on the ones that don't.
csharp
app.MapGroup("/api/auth").RequireRateLimiting("strict");
app.MapGroup("/api/reports").RequireRateLimiting("expensive");
app.MapGroup("/api/lookup").RequireRateLimiting("standard");
Partition by client, not just globally
A global rate limit protects your infrastructure but doesn't protect individual consumers from each other. If you're serving multiple API clients or tenants, partition the limiter by API key or user ID so one noisy client can't consume the shared budget that other clients depend on.
csharp
options.AddPolicy("per-client", context =>
RateLimitPartition.GetTokenBucketLimiter(
context.User.GetClientId(),
_ => new TokenBucketRateLimiterOptions { TokenLimit = 50, TokensPerPeriod = 10, ReplenishmentPeriod = TimeSpan.FromSeconds(10) }));
This is the piece that would have prevented the incident that first got me paying attention to this. One noisy client, isolated to their own budget, never should have been able to touch anyone else's experience.
Give consumers something to work with
A rejected request should tell the caller when they can try again, not just fail silently. Retry-After headers turn a rate limit from a confusing dead end into something a well-behaved client can actually respect and build around.
csharp
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.Headers.RetryAfter = "60";
await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Try again later.", token);
};
The real value
Rate limiting isn't primarily about stopping bad actors, most of the time it never gets tested by one. It's about making sure one client's usage pattern, intentional or accidental, can't degrade the experience for everyone else sharing the same infrastructure. That's a stability property every production API needs, whether or not you ever expect abuse.