Rate Limiting
Rate limiting is a key technique for controlling how frequently clients can access your API within a given time frame.
Let’s explore why rate limiting matters, the most popular algorithms, and how to implement them easily and effectively in ASP 😉.
Understanding Rate Limiting
Rate limiting is a technique used to control how many requests a client can perform within a given time period.
When limits are applied, some requests are allowed to pass through, while others are rejected once the threshold is reached.
You can implement rate limiting on the client side to delay requests before they are sent, but server-side rate limiting is enforced centrally by the server, making it more secure, flexible, and reliable for protecting system resources. Therefore, today we will focus mainly on it 😅.
Why Use Rate Limiting
Rate limiting helps to protect systems from overload, abuse, and resource exhaustion:
- preventing abuse: protect APIs from excessive or malicious requests
- improving performance: maintains responsiveness
- managing cost: helps predict and control usage-based expenses
- ensuring fair usage: all users get equitable access to resources
- enhancing security: mitigates risks like DoS attacks by restricting request rates
Enable Rate Limiting in ASP
In the past, you often had to implement rate-limiting algorithms yourself or rely on third-party NuGet packages. Today, that extra effort is no longer necessary.
Microsoft recognized how complex and error-prone these solutions can be and provided built-in support out of the box 😅.
Now, you simply register the required services, select one of the available algorithms, apply the corresponding middleware, and boom. Everything works.
var builder = WebApplication.CreateBuilder(args);
// 👇 register rate limiting services
builder.Services.AddRateLimiter(options =>
{
// 👇 choose rate limiting algorithm
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
return RateLimitPartition.GetNoLimiter(partitionKey);
});
});
var app = builder.Build();
// 👇 use rate limiting middleware
app.UseRateLimiter();
app.Run();Note: Behind the scenes, the ASP rate-limiting facilities is built on top of the
System.Threading.RateLimiting. It can be used, if you need to apply rate limiting beyond HTTP requests—such as limiting concurrent access to other resources.
Rate Limiting Algorithms
Several algorithms are widely used to limit the rate of requests in a system.
You can also implement your own rate-limiting strategy, though I can’t imagine why anyone would be desperate enough to do that 😅.
Therefore, we will just overview the most popular ones:
— Concurrency Limit
This is the simplest form of rate limiting. It just limits how many requests can be processed at the same time.
Example: “Allow at most 2 running requests”.
This algorithm is simple, prevents request pile-ups, thread exhaustion, and works well even when request rates are unpredictable.
However, it goes with several issues:
- long-running requests reduce overall throughput
- can cause unfairness (slow clients block fast ones)
- not suitable as the only protection against DDoS or traffic spikes
In ASP, this strategy can be configured as follows:
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
string partitionKey = "global";
// 👇 choose rate limiting algorithm
return RateLimitPartition.GetConcurrencyLimiter(
partitionKey,
partition => new ConcurrencyLimiterOptions
{
PermitLimit = 2, // allow 2 concurrent requests
});
});
});This method is recommended when your main risk is resource exhaustion, not traffic volume:
- you care more about system stability than request throughput
- request processing time is variable (some requests finish fast, others take seconds)
- you have hard resource caps (limited CPU, memory, DB connections, fixed thread pool)
- requests are expensive or long-running (heavy DB queries, file exports, etc)
— Fixed Window
A fixed window limit applies request caps over a fixed time span. When the window expires, the limit resets, and counting starts from the beginning.
Example: “Allow 5 requests every 1 minute”.
While this is a popular approach, since it provides a predictable request limit per time interval and prevents excessive requests over a set period, you can quickly spot problems here:
- not smooth for evenly spaced traffic
- allows bursts at the start of the window (all requests can come at once)
- does not handle bursts of requests at the end of the window - does not prevent system overload within a burst of requests
The following configuration enables this rate-limiting strategy:
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
string partitionKey = "global";
// 👇 choose rate limiting algorithm
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey,
partition => new FixedWindowRateLimiterOptions
{
PermitLimit = 5, // 5 requests
Window = TimeSpan.FromMinutes(1), // per 1 minute
});
});
});Use Fixed Window when you want to enforce a predictable request limit over a set time period:
- Public APIs with clear quotas (per-day, per-month)
- enforcing fair usage per client or API key
- when simplicity and easy monitoring are more important than perfectly smooth request distribution
— Sliding Window
As an alternative to the previous algorithm, we have the sliding window. It divides the window into segments for more fine-grained limits.
- Requests in the window are limited to
PermitLimitnumber - The window is divided into
SegmentsPerWindowsegments (e.g. 3 segments) - The window moves forward one segment at a time, based on the
segment interval(window duration / segments per window) - When a segment expires, it frees up its used limit, restoring window capacity
Example: “5 requests per 1 minute, which gets re-evaluated every 20 seconds”.
It gives smoother request distribution with more precise rate control and fairer handling for evenly spaced client requests.
Get iamprovidence’s stories in your inbox
Join Medium for free to get updates from this writer.
You can implement this approach using the configuration below:
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
string partitionKey = "";
// 👇 choose rate limiting algorithm
return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey,
partition => new SlidingWindowRateLimiterOptions
{
PermitLimit = 5, // 5 requests per window
Window = TimeSpan.FromMinutes(1), // window time equals 1 minute
SegmentsPerWindow = 3, // divides window into 3 smaller segments, 20 seconds each
});
});
});It works great when you want to enforce request limits smoothly over time, avoiding bursts that occur at fixed window boundaries.
— Token Bucket
In this algorithm, each request consumes a token, and tokens are refilled at a fixed rate. If we run out of tokens, requests are rejected.
- A bucket that has a maximum capacity of tokens (e.g., 10 tokens)
- Tokens are added to the bucket at a fixed rate (e.g., 2 tokens every 5 seconds)
- Each request consumes one token
- If there are enough tokens, the request is allowed, and the token is removed; otherwise, it is dropped
Example: “10 requests max, which refills every 5 seconds”.
The token bucket approach is popular since it:
- allows short spikes without exceeding the long-term rate
- smooths traffic more effectively
- flexible for both rate limiting and burst handling
This behavior can be enabled with the following setup:
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var partitionKey = "global";
// 👇 choose rate limiting algorithm
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey,
partition => new TokenBucketRateLimiterOptions
{
TokenLimit = 10,
TokensPerPeriod = 2,
ReplenishmentPeriod = TimeSpan.FromSeconds(5),
});
});
}Useful when you want to control the long-term request rate while still allowing short bursts of traffic:
- Public APIs where occasional short-term spikes are acceptable, but the overall rate must be limited
- endpoints where you want both fairness and flexibility for clients
- situations where average throughput matters more than instantaneous requests
— Leaky Bucket
In this strategy, traffic is smoothed to a steady flow. Requests are added to a queue (the “bucket”) and processed at a constant rate. If the bucket is full, new requests are rejected.
Example: “Process 1 requests every second, with a maximum queue of 100.”
The leaky bucket is about smoothing, not just limiting.
While it prevents traffic spikes completely, it can increase latency due to queuing.
This strategy isn’t directly supported in ASP.NET, but you can get close with an approximate setup.
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var partitionKey = "global";
// 👇 choose rate limiting algorithm
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey,
partition => new TokenBucketRateLimiterOptions
{
TokenLimit = 1, // 👈
TokensPerPeriod = 1,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 100, // 👈 bucket capacity
});
});
}Apply this approach when avoiding spikes is more important than low latency, or you want to protect legacy or sensitive backends that break under pressure.
Request Queuing
If you read carefully, you may notice that the Leaky Bucket algorithm uses a new parameter called QueueLimit. In fact, this parameter is supported by all rate-limiting algorithms.
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
string partitionKey = "global";
return RateLimitPartition.GetConcurrencyLimiter(
partitionKey,
partition => new ConcurrencyLimiterOptions
{
. . .
QueueLimit = 5, // 👈
QueueProcessingOrder = QueueProcessingOrder.OldestFirst, // 👈
. . .
});
});
});If queuing is enabled, requests that exceed the limit are queued instead of rejected, waiting for an available permit. This not only allows limiting the request rate, but also produces a steadier flow.
This could remind you of the Queue-Based Load Leveling Pattern, where incoming work is buffered and processed at a controlled rate to protect downstream resources.
Partitions
If you read really carefully, you may notice that there is another parameter partitionKey that has not been explained yet 😅.
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
string partitionKey = "global"; // 👈
return RateLimitPartition.GetConcurrencyLimiter(
partitionKey,
partition => new ConcurrencyLimiterOptions());
});
});Right now, the same rate-limiting algorithm is applied globally to all users. However, you may want to apply limits only to specific users or groups.
This way:
- one user cannot exhaust the rate limit for everyone else
- different limits can be applied per user, client, or resource
- it gives a stronger protection against targeted abuse
Partitions divide incoming traffic so each group is tracked and limited independently.
It is common to use the IP address, API key, user identity, tenant ID, or endpoint path, as a partition key.
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, IPAddress>(httpContext =>
{
// 👇
IPAddress partitionKey = context.Connection.RemoteIpAddress;
// string apiKey = httpContext.Request.Headers["API-Key"].ToString() ?? "no-key";
// string identity = httpContext.User.Identity?.Name ?? "anonymous";
// string tenantId = httpContext.RequestServices.GetRequiredService<ICurrentTenantContext>().GetTenantId();
// string path = httpContext.Request.Path.ToString().Containts("/api") ? "public-api": "private-api";
return RateLimitPartition.GetConcurrencyLimiter(
partitionKey,
partition => new ConcurrencyLimiterOptions());
});
});Granularity
So far, all examples have applied rate limiting globally, meaning the same rules affect every request across the entire application.
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = /**/
});In practice, this level of control is often too coarse 😅.
ASP allows you to define a rate-limiting policy:
builder.Services.AddRateLimiter(options =>
{
options.AddConcurrencyLimiter("my-concurrent-policy", opt => opt.PermitLimit = 5);
options.AddSlidingWindowLimiter("my-window-policy", opt => opt.PermitLimit = 5);
. . .
}That can be applied at different levels of granularity:
- globally — a single policy applies to all requests
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers().RequireRateLimiting("my-concurrent-policy");
});- per endpoint or controller — different APIs can have different limits
app.MapGet("/api/public", () => "Public API");
app.MapGet("/api/special", () => "Special API").RequireRateLimiting("my-window-policy");
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("my-window-policy")] // 👈 per controller
public class ProductsController : ControllerBase
{
[HttpGet("all")]
[EnableRateLimiting("my-concurrent-policy")] // 👈 per action
public string[] GetAll()
{
return new[] { "Product1", "Product2" };
}
}- disabled entirely — selected endpoints can opt out of rate limiting
app
.MapGet("/api/unlimited", () => "No limit here")
.DisableRateLimiting(); // 👈 per endpoint
[ApiController]
[Route("api/[controller]")]
[DisableRateLimiting] // 👈 per controller
public class ProductsController : ControllerBase
{
[HttpGet("all")]
[DisableRateLimiting] // 👈 per action
public string[] GetAll()
{
return new[] { "Product1", "Product2" };
}
}This flexibility allows you to tailor rate limiting to real usage patterns:
- public endpoints can be tightly limited
- internal or trusted endpoints can have relaxed limits
Custom Rejection Handling
ASP also allows you to define how the application responds when a request is rejected due to rate limiting.
You can set a custom status code, add headers like Retry-After, return a custom response, or log the event for monitoring purposes
builder.Services.AddRateLimiter(options =>
{
// Rate limiter configuration
options.GlobalLimiter = /**/
// Set a custom status code for rejections
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Custom rejection handling logic
options.OnRejected = async (context, cancellationToken) =>
{
var response = context.HttpContext.Response;
response.StatusCode = StatusCodes.Status429TooManyRequests;
response.Headers["Retry-After"] = "60";
await response.WriteAsync("Rate limit exceeded. Please try again later.", cancellationToken);
logger.LogWarning("Rate limit exceeded");
};
});This is useful to better inform clients when and why a request was rejected, and to provide hints for retry.
Summary
Whether you choose global limits, per-endpoint policies, or tenant-specific partitions, you can control request flow, protect backend resources, and provide a smoother experience for clients.
Implementing rate limiting in your application ensures stability, security, fairness, and allows you to enforce quotas on your API😁.
Try it. Maybe this is exactly the solution you are looking for 😉.
Do not forget to share the article🔄 Clap👏 Support ☕️ And follow ✅ See you around 🙌









