Rate limiting in ASP.NET Core
Rate limiting caps how many requests one client can make in a window. It stops a bursty caller from starving everyone else, and it keeps a slow backend from falling over. ASP.NET Core ships four limiters in Microsoft.AspNetCore.RateLimiting, and each one below has a live demo you can change.
The four limiters
| Limiter | What it counts | Good for |
|---|---|---|
| Fixed window | Requests in a window that resets | Simple per-user caps |
| Sliding window | Requests in a rolling window of segments | Fewer bursts at window edges |
| Token bucket | Tokens that refill at a set rate | Bursts with a steady average |
| Concurrency | Requests running at the same time | Slow, expensive endpoints |
Fixed window is the simplest. Sliding window smooths the edge where one window ends and the next starts. Token bucket lets a client burst up front, then settle at the refill rate. Concurrency ignores time and caps work in flight.
Which one should I use?
| You want | Use |
|---|---|
| A simple per-user cap, and edge bursts are fine | Fixed window |
| The same, but without edge bursts | Sliding window |
| Bursts up front, then a steady average | Token bucket |
| A cap on work in flight, ignoring time | Concurrency |
Start with fixed window. Move to sliding or token bucket only when the edge burst or the burst shape actually causes a problem.
Setup
Add the limiter to the service collection, then add the middleware. Attach a policy to an endpoint with RequireRateLimiting.
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("fixed", o =>
{
o.PermitLimit = 4;
o.Window = TimeSpan.FromSeconds(10);
o.QueueLimit = 2;
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/", () => "ok").RequireRateLimiting("fixed");
app.Run();
Requests past the limit get 429 by default. QueueLimit lets a few extra requests wait in a queue instead of failing straight away.
Fixed window
A fixed window allows PermitLimit requests inside each Window, then resets the counter when the window ends. The reset is the weakness: a client can send PermitLimit requests at the end of one window and PermitLimit more at the start of the next, for double the limit in one burst.
options.AddFixedWindowLimiter("fixed", o =>
{
o.PermitLimit = 4;
o.Window = TimeSpan.FromSeconds(10);
o.QueueLimit = 2;
});
Sliding window
A sliding window splits the Window into SegmentsPerWindow segments and rolls them forward. The count is the sum of the segments, so a burst spreads out instead of hitting the edge of a fixed window. It costs more memory: one counter per segment.
options.AddSlidingWindowLimiter("sliding", o =>
{
o.PermitLimit = 6;
o.Window = TimeSpan.FromSeconds(10);
o.SegmentsPerWindow = 4;
});
Token bucket
A token bucket holds up to TokenLimit tokens and refills at TokensPerPeriod every ReplenishmentPeriod. Each request takes one token. A client can burst until the bucket is empty, then it is capped at the refill rate.
options.AddTokenBucketLimiter("token", o =>
{
o.TokenLimit = 10;
o.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
o.TokensPerPeriod = 2;
o.AutoReplenishment = true;
});
Concurrency
A concurrency limiter caps how many requests run at once. It ignores how long each request takes and how many arrive in a window. Use it for endpoints that are expensive to run in parallel, like a call to an LLM or a report build.
options.AddConcurrencyLimiter("concurrency", o =>
{
o.PermitLimit = 4;
o.QueueLimit = 2;
});
Per-client limits
The limiters above are global. To limit per user or per IP, return a partition with a key.
options.AddPolicy("per-ip", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "anon",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1)
}));
Each key gets its own counter, so one client hitting the limit does not block the rest.
Rejection responses
Return something useful instead of a bare 429. The OnRejected callback sets the status, the Retry-After header, and a body.
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers.RetryAfter = "10";
await context.HttpContext.Response.WriteAsync(
"Slow down. Try again later.", cancellationToken);
};
Comments