One misbehaving client is enough to take an API down.
It doesn't have to be an attacker.
A mobile app with a bug in its retry loop.
A partner integration that polls your endpoint every 100 milliseconds.
A script a colleague wrote to "quickly export the data".
Every one of those requests hits your database, your cache and your thread pool exactly like a legitimate one.
Rate limiting is the guardrail that prevents a single caller from consuming everything.
You probably know that ASP .NET Core has a built-in rate limiter.
Do you know the four different algorithms and how each one behaves under the same traffic conditions?
After reading, you will be able to pick the right algorithm for each endpoint and wire it up in minutes.
In this post, we will explore:
- Why Rate Limiting Matters and How It Works in ASP .NET Core
- Fixed Window Limiter
- Sliding Window Limiter
- Token Bucket Limiter
- Concurrency Limiter
- Choosing the Right Algorithm
- Partitioning by IP, User, and API Key
- Handling Rejected Requests and Retry-After
Let's dive in.
👉 Read original article on my newsletter: https://antondevtips.com/blog/aspnet-core-rate-limiting-algorithms-explained
Why Rate Limiting Matters and How It Works in ASP .NET Core
Rate limiting is a rule that says: "this caller may do this much work in this much time, and no more".
It does three jobs:
- Protects from abuse: brute-force logins, scraping, and denial-of-service floods are cut off before they reach your handlers.
- Protects from accidents: a broken retry loop is stopped before it takes the database down for everyone.
- Enforces fair share: a free tier gets a small allowance, a paid tier a bigger one, and one noisy client cannot starve the rest.
Since .NET 7, rate limiting is built into ASP .NET Core.
The algorithms live in System.Threading.RateLimiting, the middleware in Microsoft.AspNetCore.RateLimiting.
Both ship with the framework, so you don't need to install any NuGet packages.
Wiring it up takes three steps: register policies, add middleware and attach a policy to an endpoint.
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(/* ... */);
builder.Services.AddAuthorization();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Policies are registered here, one per section below
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapPost("/api/auth/login", LoginEndpoint.Handle)
.RequireRateLimiting("login");
app.Run();
AddRateLimiter registers the options object that holds every named policy.
UseRateLimiter adds the middleware that checks each request against the policy attached to its endpoint.
RequireRateLimiting("login") attaches a policy middleware by name.
Endpoints without a policy are not limited at all.
Middleware order matters.
UseRateLimiter sits after UseAuthentication, so the user's claims are populated when the limiter runs, which makes per-user limits possible later.
Now let's explore the Rate Limiting algorithms.
Fixed Window Limiter
Imagine a bakery that sells 100 buns per hour.
At 9:00, the counter resets to 100. When the 100th bun is served, the door closes until 10:00, when the counter resets.
It does not matter whether the buns sold out in five minutes or across the hour. Only the clock brings buns back.
That is the fixed window limiter: a counter and a reset time.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("login", limiter =>
{
limiter.PermitLimit = 5;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});
app.MapPost("/api/auth/login", async (
LoginRequest request,
LoginHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(request, cancellationToken);
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
})
.RequireRateLimiting("login");
PermitLimit is how many requests the window allows. Window is how long until the counter resets.
QueueLimit controls what happens after the requests are gone.
With 0, the request is rejected immediately.
With 10, up to ten requests wait for the next window instead.
QueueProcessingOrder determines whether the oldest or newest waiting request receives the first available permit.
For login, rejecting immediately is the right call. Queueing a brute-force attempt only keeps a connection open for the attacker.
Over time:
10:00:00 window opens, 5 requests available
10:00:01 requests 1 to 5 -> 200 OK
10:00:02 request 6 -> 429 Too Many Requests
10:00:59 request 7 -> 429 Too Many Requests
10:01:00 window resets, 5 requests available again
The simplicity has a well-known weak spot: the window boundary.
A client can send 5 requests at 10:00:59 and another 5 at 10:01:00.
That is 10 requests in two seconds against a limit of "5 per minute".
At the boundary, a fixed window lets through up to double the limit.
For login, this is harmless. Ten password guesses instead of five changes nothing. For a downstream that can handle exactly 100 requests per minute, 200 in two seconds is a real problem.
Note: the
AddFixedWindowLimiterhelper creates one counter shared by every caller of the policy. With the code above, five failed logins from anyone lock the endpoint for the whole world. In the "Partitioning" section, I will show how to give every client its own counter.
When to use the fixed window:
- Login, password reset, email sending: cheap to count, and a small overshoot at the boundary does not matter.
- Simple abuse caps: "no more than N attempts, then wait" is easy to explain to users and support staff.
- Memory-sensitive scenarios: the cheapest algorithm, one counter and one timestamp per partition.
When not to use it:
- When the boundary burst matters: a downstream with a hard cap, or a third-party API that bills per call.
- When you want smooth traffic: a fixed window does nothing to spread requests out inside the window.
- When the reset feels unfair: a client that spreads 5 requests evenly over the minute still waits a full minute for the 6th.
With the sliding window, we can fix the boundary problem.
👉 Read original article on my newsletter: https://antondevtips.com/blog/aspnet-core-rate-limiting-algorithms-explained
Top comments (0)