An API used by multiple customers rarely has the same traffic requirements for everyone. A free customer might make a few hundred requests per day, while an enterprise customer may run thousands of requests per minute through automated integrations.
Giving every customer the same limit creates two problems. Smaller customers may receive more capacity than they need, while high-volume customers may experience unnecessary throttling. A plan-based rate-limiting system allows the API provider to allocate resources according to subscription levels.
However, implementing this properly requires more than assigning different request counts to different plans. The system must account for burst traffic, concurrent operations, monthly quotas, distributed API instances, and changes to customer subscriptions.
Understand the Difference Between Rate Limits and Quotas
Rate limits and quotas control different aspects of API consumption.
A rate limit controls how quickly a customer can send requests. For example, a customer might be allowed to send 60 requests per minute.
A quota controls total usage over a longer period. A plan might include 100,000 requests per month, regardless of how those requests are distributed throughout the month.
Consider three subscription plans:
| Plan | Sustained rate | Burst capacity | Monthly quota |
|---|
| Free | 10 requests/minute | 20 requests | 10,000 |
| Pro | 120 requests/minute | 200 requests | 500,000 |
| Enterprise | 600 requests/minute | 1,000 requests | 5,000,000 |
These values are illustrative. Actual limits should be based on infrastructure capacity, endpoint costs, and the commercial terms of each subscription.
A customer might stay within the monthly quota but exceed the per-minute rate. The opposite is also possible: a customer may make requests slowly but eventually exhaust the monthly allowance.
Both controls are necessary when the business sells plans based on API consumption.
Choose the Right Rate-Limiting Algorithm
Different algorithms solve different traffic-control problems. Selecting one depends on whether the API needs smooth traffic, strict request windows, or protection against too many simultaneous operations.
Fixed window
A fixed-window limiter counts requests within a defined time interval.
For example, a limit of 100 requests per minute allows the customer to make up to 100 requests between 10:00 and 10:01.
The counter resets when the next minute begins.
This approach is straightforward to implement, but it has a boundary problem. A customer could send 100 requests just before the minute ends and another 100 immediately after the next minute begins.
That creates a temporary burst of 200 requests within a very short period.
Fixed windows are suitable for simple quotas, but they may not provide smooth traffic control.
Sliding window
A sliding-window limiter considers requests over a moving time interval instead of resetting everything at a fixed boundary.
For example, if the limit is 100 requests per minute, the system checks how many requests occurred during the previous 60 seconds.
This reduces the boundary problem associated with fixed windows. The trade-off is that tracking requests can require more state or segmented counters.
Token bucket
The token bucket algorithm is useful when customers need controlled bursts without exceeding a sustained request rate.
Each customer has a bucket containing tokens. A request consumes one token, and tokens are replenished over time.
For example, a Pro customer might have a bucket capacity of 200 tokens and receive 2 tokens per second.
The customer can send 200 requests immediately if the bucket is full. After that, requests are accepted as tokens become available.
This approach is useful for APIs that support occasional bursts, such as bulk operations, scheduled synchronization, and integration workloads.
Concurrency limiting
Request-rate limits do not control how many requests are being processed simultaneously.
A customer might send only 20 requests per second, but if every request takes several seconds, many operations can remain active at the same time.
A concurrency limiter controls the number of requests being processed simultaneously.
For example:
- Free: 2 concurrent requests
- Pro: 10 concurrent requests
- Enterprise: 50 concurrent requests
Concurrency limits are particularly useful for endpoints that perform expensive database queries, file processing, report generation, or external service calls.
A production API can combine token-bucket rate limits with concurrency limits to control both request frequency and resource consumption.
Design a Plan-Based Rate-Limiting Architecture
A maintainable implementation should keep subscription information separate from rate-limiter logic.
A typical request flow looks like this:
- The client sends an API request.
- Authentication identifies the customer or tenant.
- The application loads the customer's subscription and entitlements.
- The rate limiter selects the applicable policy.
- The system checks the customer's available capacity.
- The request proceeds or receives an HTTP 429 response.
- Usage information is recorded for monitoring and quota accounting.
The subscription service should be the authoritative source of plan information. Do not trust a plan name supplied directly by the client.
For example, a request header containing X-Plan: Enterprise must never grant enterprise-level limits by itself.
The application should derive the customer's plan from a trusted database record or a securely issued entitlement claim.
A simple plan configuration in C# might look like this:
public sealed record ApiPlan(
int TokensPerMinute,
int BurstCapacity,
int MaxConcurrentRequests,
long MonthlyQuota);
public static class ApiPlans
{
public static readonly IReadOnlyDictionary<string, ApiPlan>
Definitions = new Dictionary<string, ApiPlan>
{
["Free"] = new(10, 20, 2, 10_000),
["Pro"] = new(120, 200, 10, 500_000),
["Enterprise"] = new(600, 1000, 50, 5_000_000)
};
public static ApiPlan Get(string planName)
{
return Definitions.TryGetValue(planName, out var plan)
? plan
: Definitions["Free"];
}
}
Keeping these values in one place makes it easier to update plans without scattering subscription-specific conditions throughout controllers and endpoints.
In a larger system, plan definitions should generally be stored in a subscription or entitlement service and cached with a controlled refresh policy.
Implement Rate Limiting in ASP.NET Core
ASP.NET Core includes rate-limiting middleware through Microsoft.AspNetCore.RateLimiting.
It supports fixed-window, sliding-window, token-bucket, and concurrency limiters. It also supports partitioning, which allows different customers to receive independent limiter instances.
The following example demonstrates token-bucket policies selected using authenticated customer claims.
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;
options.AddPolicy("customer-plan", context =>
{
var tenantId = context.User.FindFirst("tenant_id")?.Value
?? "anonymous";
var planName = context.User.FindFirst("plan")?.Value
?? "Free";
var plan = ApiPlans.Get(planName);
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey: $"{tenantId}:{planName}",
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = plan.BurstCapacity,
TokensPerPeriod = plan.TokensPerMinute,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
AutoReplenishment = true,
QueueLimit = 0
});
});
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode =
StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers["Retry-After"] = "60";
await context.HttpContext.Response.WriteAsync(
"API request limit exceeded. Please retry later.",
cancellationToken);
};
});
var app = builder.Build();
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
app.MapGet("/api/products", () =>
{
return Results.Ok(new
{
Message = "Products returned successfully"
});
})
.RequireRateLimiting("customer-plan");
app.Run();
The example uses illustrative claims and plan values. Authentication must validate the identity and tenant, and the plan claim must come from a trusted entitlement source.
The token-bucket configuration provides the specified burst capacity and replenishes the configured tokens each minute. It does not enforce the monthly quota or the maximum concurrent request count. Those controls need separate implementations.
There is also an important deployment limitation: the built-in limiter maintains its state inside the application process. If the API runs on several instances, each instance can maintain its own bucket. A customer may therefore receive more capacity than intended when requests are distributed across instances.
This implementation is useful for a single-instance application or local enforcement, but it should not be treated as a globally coordinated limit for a distributed production API.
Enforce Limits Across Multiple API Instances
Distributed applications need shared rate-limit state.
Imagine an API running on four application instances behind a load balancer. If each instance independently allows 100 requests per minute for a customer, the customer might effectively receive up to 400 requests per minute.
A shared store such as Redis can coordinate token consumption across instances.
A distributed token-bucket implementation typically stores:
- Available tokens.
- Last refill timestamp.
- Bucket capacity.
- Refill rate.
The application performs the token calculation and update atomically. Redis Lua scripts are one way to achieve this because the calculation and state update execute as a single operation.
The key should identify the customer and the applicable policy:
rate-limit:tenant-104:pro
Avoid using untrusted, unrestricted input as a partition key. An attacker could generate thousands of distinct identifiers and cause unnecessary state growth.
The distributed operation should also handle Redis failures deliberately. For example, the application may reject requests when the limiter is unavailable for a sensitive endpoint, while another endpoint may use a carefully bounded fallback. The decision depends on the business risk.
A distributed limiter adds network latency and operational complexity, so measure its performance under realistic concurrent traffic.
Handle Monthly Quotas Separately
A monthly quota is not the same as a token bucket.
Token buckets replenish continuously. Monthly quotas generally accumulate usage over a billing period and do not automatically reset every minute.
A usage record might contain:
TenantId
BillingPeriod
RequestsConsumed
LastUpdatedAt
Before processing a request, the API checks whether the customer's remaining quota is sufficient.
For high-volume APIs, avoid updating a central database row for every request if that creates a bottleneck. A common approach is to maintain short-term counters in a distributed store and periodically reconcile usage with the billing system.
However, the system must define what happens when the monthly limit is reached. Possible policies include:
- Reject additional requests until the next billing period.
- Allow additional usage with overage billing.
- Notify the customer and offer a plan upgrade.
- Allow a configured grace allowance.
For expensive operations, usage may need to be measured in units rather than requests. A report-generation request, for example, might consume more resources than a simple status lookup.
A weighted usage model can charge different numbers of units for different endpoint categories.
Return Useful Information When a Customer Is Throttled
An HTTP 429 response tells the client that the request was rejected because of rate limiting.
It should also help the client understand when it can retry.
For example:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"message": "Your request limit has been reached.",
"retryAfterSeconds": 30
}
The Retry-After value should reflect the actual retry timing when it can be calculated. A fixed value in sample code is not a complete production implementation.
The API can also expose the standardized RateLimit response fields defined by the HTTP rate-limit specification, where supported by the implementation. These fields allow clients to understand the applicable limit, remaining capacity, and reset information.
Avoid exposing internal customer identifiers, billing details, or implementation-specific infrastructure information in error responses.
Client applications should use bounded exponential backoff with jitter when retrying temporary rate-limit failures. Sending repeated requests immediately after receiving a 429 response can make the situation worse.
Common Mistakes in Plan-Based Rate Limiting
Several implementation mistakes can undermine an otherwise reasonable design.
- Using API keys as the only authentication mechanism. API keys identify clients but should not replace proper authentication and authorization.
- Trusting a plan name supplied by the request. Subscription entitlements must come from a trusted source.
- Applying only per-IP limits. Multiple customers may share an IP address, and one customer may use many IP addresses.
- Using in-memory counters across multiple instances. Each instance can enforce a separate limit.
- Treating monthly quotas as token-bucket limits. These mechanisms have different reset and accounting behavior.
- Ignoring expensive endpoints. A request that generates a large report should not necessarily cost the same as a simple read.
- Allowing unlimited queues. Requests waiting for a rate-limit slot can consume memory and increase response latency.
- Forgetting plan changes. Upgrades, downgrades, suspended accounts, and expired subscriptions must update effective limits.
These issues should be included in load tests and subscription lifecycle tests, not discovered after customers begin reporting unexpected throttling.
Conclusion
A plan-based API rate limiter should balance customer entitlements with the actual resource capacity of the platform.
Use token buckets when customers need controlled bursts, sliding windows when smoother time-based limits are important, and concurrency limits for expensive operations. Track monthly quotas independently and use shared state when requests are distributed across multiple API instances.
Keep subscription entitlements authoritative, make throttling responses useful to API consumers, and test the system under concurrent traffic.
The objective is not simply to reject excess requests. It is to give each customer predictable API access while protecting the reliability and operating costs of the service.
API rate limitingAPI quotastoken bucket algorithmconcurrency limitingsubscription plansC#
Join the conversation! Your thoughts help the community grow.