Rate Limiting Algorithms: The Complete Guide
A deep dive into every rate limiting algorithm — token bucket, leaky bucket, fixed window, sliding window log, and sliding window counter — with production-grade C# implementations, Redis distributed patterns, and system design interview preparation.
1. Why Rate Limiting Matters in Modern Systems
Every API, every microservice, and every network endpoint on the modern internet faces a fundamental truth: unbounded request rates destroy systems. Rate limiting is the practice of controlling the number of requests a client can make to a service within a given time window. It is not optional. Without rate limiting, a single misbehaving client, a runaway script, or a malicious attacker can saturate your servers, exhaust your database connections, consume all available memory, and bring down the entire platform for every user. Rate limiting is the firewall that stands between your infrastructure and catastrophe.
Consider the numbers. A single automated client without rate limiting can generate millions of requests per minute. AWS API Gateway limits accounts to 10,000 requests per second by default. GitHub enforces 5,000 requests per hour for authenticated users and 60 per hour for unauthenticated requests. Twitter's API has tiered limits ranging from 15 to 900 requests per 15-minute window depending on the endpoint. These are not arbitrary numbers — they are carefully tuned thresholds that balance usability with infrastructure protection. The consequences of getting rate limiting wrong are severe: degraded service for legitimate users, unexpected cloud bills from resource consumption, database connection pool exhaustion, and cascading failures across microservice boundaries.
Rate limiting serves five critical purposes in modern systems. First, it protects against denial-of-service attacks by capping the maximum request rate a single source can generate. Second, it ensures fair resource allocation among tenants in multi-tenant platforms — one customer cannot starve others. Third, it controls costs in pay-per-use cloud architectures where every API call incurs a charge. Fourth, it provides back-pressure signals to upstream services, preventing cascade failures. Fifth, it enforces contractual SLAs by ensuring that partner integrations do not exceed negotiated throughput limits. In this guide, we will implement every major algorithm from scratch, understand the mathematical properties that make each suitable for different use cases, and build production-grade distributed rate limiters using Redis and C#.
2. Fundamental Concepts and Terminology
Before diving into individual algorithms, let us establish a common vocabulary. A rate limit defines the maximum number of requests allowed per client within a specified time window. The client is identified by a key — typically a user ID, API key, IP address, or a combination of these. The window (or time bucket) is the duration over which requests are counted. A window can be fixed in time (aligned to clock boundaries) or sliding (relative to the current moment). The quota is the maximum number of requests allowed within a window. When a request exceeds the quota, it is throttled (rejected with HTTP 429) or queued for later processing depending on the algorithm.
There are two fundamental approaches to rate limiting: client-side and server-side. Client-side rate limiting involves the client voluntarily respecting a rate announced by the server via response headers (such as X-RateLimit-Limit and X-RateLimit-Reset). This is unreliable because clients can ignore the headers. Server-side rate limiting enforces limits at the infrastructure layer using middleware, API gateways, or dedicated rate limiting services. Production systems always implement server-side rate limiting as the authoritative enforcement mechanism, with client-side limiting as an optimization to reduce unnecessary round trips.
Rate limiters can be applied at multiple layers of the network stack. At the load balancer level, NGINX and Envoy can enforce connection-level limits. At the API gateway level, Kong, AWS API Gateway, and Azure API Management apply per-endpoint and per-consumer limits. At the application level, middleware in ASP.NET Core, Spring Boot, or Express.js enforces business-specific rules. At the database level, connection pool limits and query rate limits prevent runaway queries. The choice of where to apply rate limiting depends on your architecture — most production systems apply it at multiple layers for defense in depth. Each layer catches different types of abuse: network-level limits stop volumetric DDoS attacks, API gateway limits enforce contractual quotas, and application-level limits enforce business logic constraints.
Rate Limiting vs Throttling vs Backpressure
These terms are related but distinct. Rate limiting sets a hard cap on requests per time window and rejects excess requests immediately. Throttling slows down the processing of requests rather than rejecting them — it degrades performance gracefully instead of returning errors. Backpressure is a flow control mechanism where a downstream service signals to an upstream caller to slow down, typically via HTTP 503 or gRPC UNAVAILABLE status codes. Rate limiting is the most common of these three in API design, but understanding all three is essential for system design interviews. In practice, many rate limiters implement a hybrid approach: they rate limit within normal operating parameters and switch to throttling when the system is under extreme load, queuing excess requests and processing them at a reduced rate.
Key Properties of Rate Limiters
Every rate limiter has four key properties that determine its suitability. Accuracy measures how closely the enforced rate matches the configured limit. Memory efficiency measures how much storage is needed per tracked key. Burst tolerance determines whether the limiter allows traffic spikes or enforces a strict constant rate. Distributed coordination measures how well the algorithm scales across multiple application instances using a shared counter store. The ideal rate limiter has perfect accuracy, minimal memory usage, appropriate burst tolerance, and seamless distributed coordination. In practice, every algorithm makes trade-offs across these dimensions.
| Property | Description | Why It Matters |
|---|---|---|
| Accuracy | How closely enforced rate matches configured limit | Inaccurate limiters either block legitimate traffic or allow abuse |
| Memory | Bytes required per tracked key | At scale, memory determines Redis cluster size and cost |
| Burst tolerance | Whether traffic spikes are allowed | APIs need bursts; background processing needs smoothness |
| Distributed support | Coordination across multiple instances | Essential for horizontally scaled deployments |
3. Token Bucket Algorithm — Deep Dive
The token bucket algorithm is the most widely used rate limiting algorithm in production systems. It was popularized by the IETF in RFC 2697 (Single Rate Three Color Marker) and RFC 4155 (Two Rate Three Color Marker) for network traffic shaping. The algorithm is conceptually simple: imagine a bucket that holds tokens. Tokens are added at a fixed rate (the refill rate) up to a maximum capacity (the bucket size). When a request arrives, it must consume one or more tokens. If the bucket has enough tokens, the request is allowed and tokens are deducted. If the bucket is empty, the request is rejected. During idle periods, tokens accumulate up to the capacity, which naturally allows bursts of traffic equal to the bucket size.
The token bucket has two tunable parameters: the refill rate (how many tokens are added per second) and the capacity (the maximum number of tokens the bucket can hold). For example, an API with a rate of 100 requests per second and a capacity of 50 means the system can sustain 100 req/s indefinitely but allows bursts up to 150 requests in a single second (50 accumulated tokens plus the current second's 100 tokens). This burst behavior makes the token bucket ideal for web APIs where traffic is naturally bursty — a user might click multiple buttons in quick succession, and those requests should be served without artificial delays.
The memory footprint of a token bucket is remarkably small. For each tracked key, you only need to store two values: the current token count and the timestamp of the last refill. This is typically 16 bytes of data (a double for the count and a long for the timestamp) plus the key string itself. For a system tracking 10 million users, this amounts to approximately 160 MB of data — trivial for a Redis instance. The computational cost per check is also minimal: calculate the elapsed time since the last refill, add the appropriate number of tokens (capped at capacity), check if enough tokens are available, and deduct if so. This is O(1) time complexity.
Token Bucket in C#
C#
public class TokenBucket
{
private readonly double _refillRate;
private readonly int _capacity;
private double _tokens;
private long _lastRefillTicks;
private readonly object _lock = new();
public TokenBucket(int requestsPerSecond, int capacity)
{
_refillRate = requestsPerSecond;
_capacity = capacity;
_tokens = capacity;
_lastRefillTicks = Stopwatch.GetTimestamp();
}
public bool TryConsume(int tokensRequested = 1)
{
lock (_lock)
{
Refill();
if (_tokens >= tokensRequested)
{
_tokens -= tokensRequested;
return true;
}
return false;
}
}
private void Refill()
{
long now = Stopwatch.GetTimestamp();
double elapsed = (now - _lastRefillTicks)
/ (double)Stopwatch.Frequency;
_tokens = Math.Min(
_capacity,
_tokens + elapsed * _refillRate);
_lastRefillTicks = now;
}
public RateLimitStatus GetStatus()
{
lock (_lock)
{
Refill();
return new RateLimitStatus
{
Allowed = _tokens > 0,
Remaining = (int)_tokens,
Capacity = _capacity,
RefillRate = _refillRate
};
}
}
}
public class RateLimitStatus
{
public bool Allowed { get; set; }
public int Remaining { get; set; }
public int Capacity { get; set; }
public double RefillRate { get; set; }
}
When to Use Token Bucket
Use the token bucket when your API needs to tolerate legitimate bursts of traffic while enforcing a sustained average rate. It is the default algorithm for AWS API Gateway, Shopify's storefront API, Stripe's API, and most commercial API management platforms. The token bucket is also an excellent choice for client-side rate limiting in mobile and web applications because its burst tolerance matches typical user behavior patterns — a user loads a page (burst of 5-10 requests), then idles while reading content. The token bucket handles this naturally without any special configuration.
Token Bucket — Burst Behavior Visualization
Consider a token bucket configured with a refill rate of 10 tokens per second and a capacity of 30. If the system is idle for 5 seconds, the bucket fills to its capacity of 30 tokens. A burst of 30 requests arriving simultaneously would be served immediately. After the burst, subsequent requests would be allowed at the sustained rate of 10 per second. If traffic arrives at 15 per second (50% above the refill rate), tokens deplete at a rate of 5 per second, and the bucket empties after 6 seconds of sustained over-rate traffic. This behavior provides natural smoothing: short bursts are absorbed, but sustained overload is eventually rejected.
4. Leaky Bucket Algorithm — Deep Dive
The leaky bucket algorithm approaches rate limiting from a fundamentally different angle than the token bucket. Instead of allowing bursts up to a capacity, the leaky bucket enforces a strict constant output rate by processing requests from a FIFO queue. Imagine a bucket with a small hole at the bottom. Requests pour in from the top at varying rates, but the bucket leaks (processes) requests from the bottom at a constant, configured rate. If the bucket is full when a new request arrives, the request is rejected. The leaky bucket guarantees that the output rate never exceeds the configured limit, regardless of the input rate.
The leaky bucket has two parameters: the leak rate (how many requests are processed per second) and the queue capacity (how many requests can wait in the bucket before being rejected). Unlike the token bucket where the capacity represents burst tolerance, the queue capacity in a leaky bucket represents the maximum number of pending requests. Requests in the queue experience latency proportional to their position in the queue — the deeper in the queue, the longer the wait. This latency is the key trade-off: the leaky bucket provides perfect output smoothing at the cost of variable request latency.
The memory footprint of the leaky bucket is slightly larger than the token bucket because you must store the queue. For each key, you need the queue itself (a list of timestamps or request objects) and the last leak timestamp. Under low load, the queue is typically empty or very short. Under sustained over-rate traffic, the queue fills to its capacity, and you need storage for up to capacity timestamps per key. For a capacity of 100, this is approximately 800 bytes per key (100 timestamps at 8 bytes each plus overhead). At scale, this can add up — tracking 10 million users with capacity 100 requires approximately 8 GB of Redis memory.
Leaky Bucket in C#
C#
public class LeakyBucket
{
private readonly double _leakRate;
private readonly int _capacity;
private readonly Queue<DateTime> _queue = new();
private DateTime _lastLeakTime;
private readonly object _lock = new();
public LeakyBucket(int requestsPerSecond, int capacity)
{
_leakRate = requestsPerSecond;
_capacity = capacity;
_lastLeakTime = DateTime.UtcNow;
}
public bool TryProcess()
{
lock (_lock)
{
Leak();
if (_queue.Count < _capacity)
{
_queue.Enqueue(DateTime.UtcNow);
return true;
}
return false;
}
}
public TimeSpan? GetWaitTime()
{
lock (_lock)
{
Leak();
if (_queue.Count < _capacity)
return TimeSpan.Zero;
double secondsToWait =
(1.0 / _leakRate) -
(DateTime.UtcNow - _lastLeakTime).TotalSeconds;
return TimeSpan.FromSeconds(
Math.Max(0, secondsToWait));
}
}
private void Leak()
{
DateTime now = DateTime.UtcNow;
double elapsed = (now - _lastLeakTime).TotalSeconds;
int leakedCount = (int)(elapsed * _leakRate);
int toRemove = Math.Min(leakedCount, _queue.Count);
for (int i = 0; i < toRemove; i++)
_queue.Dequeue();
if (toRemove > 0)
_lastLeakTime = now;
}
}
When to Use Leaky Bucket
Use the leaky bucket when you need to guarantee a strict maximum output rate with no tolerance for bursts. The canonical use case is outgoing API calls to third-party services with strict per-second limits. For example, if a payment gateway allows 50 requests per second and you have 500 pending requests, the leaky bucket ensures you never exceed the gateway's limit by processing exactly 50 per second regardless of the input rate. The leaky bucket is also used in network traffic policing at the packet level, in message queue consumers that need to process at a constant rate, and in database write path throttling where burst writes can cause WAL expansion and checkpoint pressure.
5. Fixed Window Counter — Deep Dive
The fixed window counter is the simplest rate limiting algorithm to understand and implement. Time is divided into fixed windows of a configured duration (e.g., 1 minute, 1 hour). Each window has a counter that starts at zero and increments with each request. When the counter reaches the limit, subsequent requests are denied until the next window begins. The window boundaries are determined by dividing the current Unix timestamp by the window size and taking the integer part. For a 60-second window, a request arriving at timestamp 1690000030 belongs to window 28166667 (1690000030 / 60 = 28166667.166...). When the timestamp crosses into window 28166668, the counter resets to zero.
The fixed window counter is memory-efficient: you need only one integer counter per key per window. For a 1-hour window, you need one key per user for the current hour. The implementation with Redis is trivial: use INCR to atomically increment the counter and EXPIRE to set the key's TTL to twice the window duration (to keep the previous window for edge case handling). The time complexity is O(1) and the space complexity is O(1) per key. This simplicity makes the fixed window counter the fastest algorithm to implement and the cheapest to operate.
However, the fixed window counter has a well-known flaw: boundary bursts. If the limit is 100 requests per minute and a user sends 100 requests at 11:59:59 and another 100 at 12:00:01, they have effectively sent 200 requests in 2 seconds. The counter resets at the fixed boundary regardless of the actual traffic pattern. This allows a client to nearly double the effective rate by timing requests to straddle window boundaries. For coarse rate limits (e.g., 10,000 requests per hour) this double-counting is negligible. For tight limits (e.g., 5 requests per minute) this can be a significant problem.
Fixed Window Counter in C#
C#
public class FixedWindowCounter
{
private readonly int _limit;
private readonly long _windowTicks;
private readonly ConcurrentDictionary<string, long> _counters = new();
public FixedWindowCounter(int limit, TimeSpan window)
{
_limit = limit;
_windowTicks = window.Ticks;
}
public bool TryAcquire(string key)
{
long windowId = DateTime.UtcNow.Ticks / _windowTicks;
string windowKey = $"{key}:{windowId}";
long count = _counters.AddOrUpdate(
windowKey, 1, (_, current) => current + 1);
return count <= _limit;
}
public (bool allowed, int remaining, DateTime reset)
CheckStatus(string key)
{
long windowId = DateTime.UtcNow.Ticks / _windowTicks;
long nextWindow = windowId + 1;
string windowKey = $"{key}:{windowId}";
_counters.TryGetValue(windowKey, out long count);
int remaining = Math.Max(0, _limit - (int)count);
DateTime reset = new DateTime(
nextWindow * _windowTicks, DateTimeKind.Utc);
return (count < _limit, remaining, reset);
}
public void Cleanup()
{
long cutoff = DateTime.UtcNow.Ticks / _windowTicks - 2;
var stale = _counters.Keys
.Where(k =>
{
var parts = k.Split(':');
return long.Parse(parts[^1]) < cutoff;
});
foreach (var key in stale)
_counters.TryRemove(key, out _);
}
}
Redis Implementation
C# Redis
public class RedisFixedWindowCounter
{
private readonly IConnectionMultiplexer _redis;
private readonly int _limit;
private readonly int _windowSeconds;
public RedisFixedWindowCounter(
IConnectionMultiplexer redis,
int limit, int windowSeconds)
{
_redis = redis;
_limit = limit;
_windowSeconds = windowSeconds;
}
public async Task<bool> TryAcquireAsync(string key)
{
var db = _redis.GetDatabase();
long windowId = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
/ _windowSeconds;
string redisKey = $"rl:{key}:{windowId}";
long count = await db.StringIncrementAsync(redisKey);
if (count == 1)
{
await db.KeyExpireAsync(redisKey,
TimeSpan.FromSeconds(_windowSeconds * 2));
}
return count <= _limit;
}
}
The fixed window counter remains useful for coarse-grained rate limiting where the boundary burst issue is acceptable. Many systems use it as a first pass — for example, limiting daily API calls per account — where the 100% burst at boundaries is insignificant relative to the overall quota. Its simplicity also makes it ideal for rate limiting in embedded systems and IoT devices where memory and compute resources are constrained.
6. Sliding Window Log — Deep Dive
The sliding window log algorithm provides perfect accuracy by maintaining a timestamp for every request within the current window. When a new request arrives, the algorithm first removes all timestamps that fall outside the current window (older than now - window_duration). It then counts the remaining timestamps. If the count is below the limit, the request is allowed and its timestamp is added to the log. If the count is at or above the limit, the request is rejected. Because the window slides continuously based on the actual request time rather than fixed clock boundaries, there is no boundary burst problem. The effective rate is always accurate within one request.
The sliding window log provides perfect accuracy — it is the gold standard against which all other algorithms are measured. However, this accuracy comes at a steep cost: memory. For a limit of 10,000 requests per hour, you store up to 10,000 timestamps per user. Each timestamp is 8 bytes. For 10 million users, that is 800 GB of timestamps — far beyond what a single Redis instance can hold. Even for more moderate limits like 1,000 requests per hour, you are looking at 80 GB. This makes the sliding window log impractical for high-limit scenarios at scale, but it works well for low-limit endpoints like password reset (5 per hour) or OTP verification (3 per 5 minutes).
In Redis, the sliding window log is implemented using sorted sets (ZSET). The score is the timestamp and the member is a unique request identifier. ZREMRANGEBYSCORE removes expired entries, ZCARD counts current entries, and ZADD adds new entries — all in O(log N) time. The Redis implementation is clean and correct, but the memory cost grows linearly with the limit value. For endpoints with tight limits (under 100 per window), the sliding window log is the ideal choice.
Sliding Window Log in C#
C#
public class SlidingWindowLog
{
private readonly int _limit;
private readonly TimeSpan _window;
private readonly ConcurrentDictionary<string,
ConcurrentQueue<DateTime>> _logs = new();
public SlidingWindowLog(int limit, TimeSpan window)
{
_limit = limit;
_window = window;
}
public bool TryAcquire(string key)
{
DateTime now = DateTime.UtcNow;
DateTime cutoff = now - _window;
var log = _logs.GetOrAdd(key,
_ => new ConcurrentQueue<DateTime>());
// Remove expired entries
while (log.TryPeek(out DateTime oldest) &&
oldest <= cutoff)
{
log.TryDequeue(out _);
}
if (log.Count < _limit)
{
log.Enqueue(now);
return true;
}
return false;
}
public int GetCurrentCount(string key)
{
DateTime cutoff = DateTime.UtcNow - _window;
if (!_logs.TryGetValue(key, out var log))
return 0;
return log.Count(t => t > cutoff);
}
}
Redis Sorted Set Implementation
C# Redis
public class RedisSlidingWindowLog
{
private readonly IConnectionMultiplexer _redis;
private readonly int _limit;
private readonly int _windowSeconds;
public RedisSlidingWindowLog(
IConnectionMultiplexer redis,
int limit, int windowSeconds)
{
_redis = redis;
_limit = limit;
_windowSeconds = windowSeconds;
}
public async Task<bool> TryAcquireAsync(
string key, string requestId)
{
var db = _redis.GetDatabase();
string redisKey = $"rl:log:{key}";
double now = DateTimeOffset.UtcNow
.ToUnixTimeMilliseconds();
double cutoff = now - (_windowSeconds * 1000);
// Remove expired entries
await db.SortedSetRemoveRangeByScoreAsync(
redisKey, 0, cutoff);
// Count current entries
long count = await db.SortedSetLengthAsync(redisKey);
if (count >= _limit)
return false;
// Add current request
await db.SortedSetAddAsync(redisKey, requestId, now);
await db.KeyExpireAsync(redisKey,
TimeSpan.FromSeconds(_windowSeconds + 1));
return true;
}
}
The sliding window log is the algorithm of choice for security-critical endpoints where even small inaccuracies are unacceptable. Password reset limits, two-factor authentication attempts, account lockout thresholds, and payment retry limits all benefit from the perfect accuracy of the sliding window log. For these endpoints, the limit is typically low (under 20 per window) and the memory cost is trivial.
7. Sliding Window Counter — Deep Dive
The sliding window counter is the practical sweet spot for most production rate limiters. It combines the memory efficiency of the fixed window counter with the accuracy of the sliding window log. The algorithm uses two counters: the counter for the previous window and the counter for the current window. The estimated request count is calculated as a weighted sum: estimated = prev_count * (1 - elapsed_fraction) + current_count, where elapsed_fraction is the proportion of the current window that has elapsed. For example, if the window is 60 seconds and 15 seconds have elapsed, the weight is 0.25, so the estimate is prev_count * 0.75 + current_count. This approximation provides smooth sliding window behavior without storing individual timestamps.
The accuracy of the sliding window counter depends on the traffic pattern. For uniform traffic, the estimate is very close to the true count. For bursty traffic concentrated at window boundaries, the estimate can be off by up to one window's worth of requests. In practice, this error is less than 5% for most real-world traffic patterns, and many rate limiting systems treat this as acceptable. The memory cost is minimal: just two counters per key, typically 16 bytes of data. For 10 million users, this is 160 MB — the same as the token bucket. This combination of near-perfect accuracy and minimal memory makes the sliding window counter the default recommendation for production API rate limiting.
Cloudflare uses the sliding window counter for their DDoS protection and rate limiting. Kong API Gateway's rate limiting plugin uses it as the default algorithm. Most cloud API gateways — AWS, Azure, GCP — offer it as an option. The algorithm has been battle-tested at enormous scale and its approximation error has been thoroughly characterized. For a detailed mathematical analysis, see the paper "A Defense Against Rate Limit Abuse in Web Applications" which shows that the weighted approach reduces the maximum burst error by approximately 50% compared to fixed window counters.
Sliding Window Counter in C#
C#
public class SlidingWindowCounter
{
private readonly int _limit;
private readonly TimeSpan _window;
private readonly ConcurrentDictionary<string,
(long prevCount, long currCount,
long prevWindow, long currWindow)> _counters = new();
public SlidingWindowCounter(int limit, TimeSpan window)
{
_limit = limit;
_window = window;
}
public bool TryAcquire(string key)
{
long now = DateTime.UtcNow.Ticks;
long currentWindowId = now / _window.Ticks;
long previousWindowId = currentWindowId - 1;
_counters.AddOrUpdate(key,
CreateEntry(currentWindowId),
(_, existing) => UpdateEntry(
existing, currentWindowId, previousWindowId));
var (prevCount, currCount, _, _) = _counters[key];
double elapsedFraction =
(double)(now % _window.Ticks) / _window.Ticks;
double estimated =
prevCount * (1 - elapsedFraction) + currCount;
return estimated < _limit;
}
private (long, long, long, long) CreateEntry(
long currentWindow)
=> (0, 1, currentWindow - 1, currentWindow);
private (long, long, long, long) UpdateEntry(
(long prev, long curr,
long prevWin, long currWin) existing,
long currentWindow, long previousWindow)
{
if (existing.currWin == currentWindow)
return (existing.prev, existing.curr + 1,
existing.prevWin, currentWindow);
if (existing.currWin == previousWindow)
return (existing.curr, 1,
existing.currWin, currentWindow);
return (0, 1, previousWindow, currentWindow);
}
}
Redis Implementation with Lua Script
C# + Lua
public class RedisSlidingWindowCounter
{
private readonly IConnectionMultiplexer _redis;
private readonly int _limit;
private readonly int _windowSeconds;
private const string LuaScript = @"
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local currentWindow = math.floor(now / window)
local prevWindow = currentWindow - 1
local currKey = key .. ':' .. currentWindow
local prevKey = key .. ':' .. prevWindow
local prevCount = tonumber(redis.call('GET', prevKey) or '0')
local currCount = tonumber(redis.call('GET', currKey) or '0')
local elapsed = (now % window) / window
local estimated = prevCount * (1 - elapsed) + currCount
if estimated + cost > limit then
return 0
end
redis.call('INCRBY', currKey, cost)
redis.call('EXPIRE', currKey, window * 2)
return 1
";
public RedisSlidingWindowCounter(
IConnectionMultiplexer redis,
int limit, int windowSeconds)
{
_redis = redis;
_limit = limit;
_windowSeconds = windowSeconds;
}
public async Task<bool> TryAcquireAsync(
string key, int cost = 1)
{
var db = _redis.GetDatabase();
long now = DateTimeOffset.UtcNow
.ToUnixTimeSeconds();
var result = await db.ScriptEvaluateAsync(
LuaScript,
new RedisKey[] { $"rl:{key}" },
new RedisValue[]
{
_limit, _windowSeconds, now, cost
});
return (int)result == 1;
}
}
The Lua script is critical for distributed correctness. Without it, the GET-then-INCR pattern in Redis is non-atomic and two concurrent requests could both read a count below the limit, both decide to allow, and both increment — resulting in an over-limit allowance. The Lua script executes atomically within Redis, ensuring that the read-check-increment is a single indivisible operation. This is the production pattern used by every serious Redis-based rate limiter.
8. Algorithm Comparison and Decision Matrix
Choosing the right rate limiting algorithm requires understanding the trade-offs across accuracy, memory, burst handling, and implementation complexity. No single algorithm is optimal for all use cases. The table below summarizes the key characteristics to guide your decision.
| Algorithm | Accuracy | Memory (per key) | Burst Handling | Implementation | Best For |
|---|---|---|---|---|---|
| Token Bucket | Good | ~16 bytes (2 values) | Allows bursts up to capacity | Simple | API rate limiting with burst tolerance |
| Leaky Bucket | Good | ~80 bytes + queue | No bursts; strict constant rate | Moderate | Outgoing API calls, traffic shaping |
| Fixed Window | Poor (boundary bursts) | ~8 bytes (1 counter) | Allows 2x burst at boundaries | Trivial | Coarse daily/hourly quotas |
| Sliding Window Log | Perfect | ~8 bytes × request count | No bursts beyond limit | Moderate | Security endpoints, low limits |
| Sliding Window Counter | Very good (~95%+) | ~16 bytes (2 counters) | Smooth; minor boundary smoothing | Moderate | General-purpose production API limiting |
Decision Flowchart
Performance Comparison at Scale
To understand the practical implications, consider a system tracking 100 million rate limit keys with an average of 1,000 requests per key per minute. The fixed window counter requires approximately 800 MB of Redis memory (one 8-byte counter per key). The token bucket requires approximately 1.6 GB (two 8-byte values per key). The sliding window counter requires approximately 1.6 GB (two counters per key). The sliding window log requires approximately 80 GB (1,000 timestamps at 8 bytes each per key). These numbers explain why the sliding window log is rarely used at high-traffic endpoints and why the sliding window counter dominates production deployments.
9. Distributed Rate Limiting with Redis
In a single-server deployment, rate limiting is straightforward: you store counters in memory and every request goes through the same process. In production, most systems run multiple application instances behind a load balancer. If each instance maintains its own counters, a user can exceed the limit by distributing requests across instances — a user with a 100 req/s limit who hits 10 instances gets 1000 req/s total. The solution is a centralized counter store that all instances share. Redis is the standard choice for this role because it provides sub-millisecond latency for atomic operations, supports rich data structures needed for various algorithms, and handles high throughput with minimal configuration.
There are three primary patterns for distributed rate limiting with Redis. The centralized pattern has every application instance query a single Redis cluster for every rate limit check. This provides perfect accuracy but adds network latency (typically 0.5-2 ms per Redis operation) and makes Redis a single point of failure. The local-plus-sync pattern maintains local in-memory counters that periodically sync with Redis, reducing Redis load by 90-99% at the cost of slight over-limit tolerance (typically 1-5% over the configured limit). The sticky-session pattern uses consistent hashing to route requests from a given client to the same application instance, reducing the need for distributed coordination. This works well for WebSocket connections but is not feasible for stateless HTTP APIs.
Centralized Redis Pattern
C#
public class DistributedRateLimiter
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<DistributedRateLimiter> _logger;
private static readonly LuaScript RateLimitScript = @"
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local currentWindow = math.floor(now / window)
local prevWindow = currentWindow - 1
local currKey = key .. ':' .. currentWindow
local prevKey = key .. ':' .. prevWindow
local prevCount = tonumber(
redis.call('GET', prevKey) or '0')
local currCount = tonumber(
redis.call('GET', currKey) or '0')
local elapsed = (now % window) / window
local estimated =
prevCount * (1 - elapsed) + currCount
if estimated + cost > limit then
local retryAfter = window - (now % window)
return {0, math.ceil(retryAfter),
math.ceil(limit - estimated),
limit, currentWindow * window + window}
end
redis.call('INCRBY', currKey, cost)
redis.call('EXPIRE', currKey, window * 2)
local remaining = math.max(0,
math.floor(limit - estimated - cost))
return {1, 0, remaining, limit,
currentWindow * window + window}
";
public DistributedRateLimiter(
IConnectionMultiplexer redis,
ILogger<DistributedRateLimiter> logger)
{
_redis = redis;
_logger = logger;
}
public async Task<RateLimitResult> CheckAsync(
string clientKey, int cost = 1,
int limit = 100, int windowSeconds = 60)
{
try
{
var db = _redis.GetDatabase();
long now = DateTimeOffset.UtcNow
.ToUnixTimeSeconds();
var result = await db.ScriptEvaluateAsync(
RateLimitScript,
new RedisKey[] { $"rl:{clientKey}" },
new RedisValue[]
{
limit, windowSeconds, now, cost
});
var values = (RedisValue[])result;
bool allowed = (int)values[0] == 1;
int retryAfter = (int)values[1];
int remaining = (int)values[2];
int limitValue = (int)values[3];
long resetAt = (long)values[4];
return new RateLimitResult
{
Allowed = allowed,
RetryAfterSeconds = retryAfter,
Remaining = remaining,
Limit = limitValue,
ResetAtUnix = resetAt
};
}
catch (Exception ex)
{
_logger.LogError(ex,
"Redis rate limit check failed " +
"for {Key}, failing open", clientKey);
return RateLimitResult.Fallback(limit);
}
}
}
public class RateLimitResult
{
public bool Allowed { get; set; }
public int RetryAfterSeconds { get; set; }
public int Remaining { get; set; }
public int Limit { get; set; }
public long ResetAtUnix { get; set; }
public static RateLimitResult Fallback(int limit)
=> new()
{
Allowed = true,
Remaining = limit,
Limit = limit,
ResetAtUnix = DateTimeOffset.UtcNow
.ToUnixTimeSeconds() + 60
};
}
Fail-Open vs Fail-Closed
The distributed rate limiter above uses a fail-open strategy: if Redis is unreachable, all requests are allowed. This is the correct default for most applications — a brief period of unlimited traffic is usually less damaging than blocking all users. However, for security-critical endpoints (login, payment), you might choose fail-closed: if Redis is unreachable, reject requests until the rate limiter is healthy. The choice depends on your risk tolerance and the cost of allowing unregulated traffic versus the cost of denying service. Document your choice and make it configurable per endpoint.
Redis Cluster for Horizontal Scaling
When a single Redis instance cannot handle the rate limiting throughput (typically above 100,000 operations per second), use Redis Cluster to shard rate limit keys across multiple nodes. Each key is assigned to a slot via consistent hashing. Redis Cluster automatically routes operations to the correct node. For rate limiting, keys are typically structured as rl:{client_id}:{window_id}, ensuring all operations for a given client in a given window go to the same node. This enables linear horizontal scaling of the rate limiting infrastructure. Monitor Redis Cluster memory and throughput metrics to determine when sharding is needed.
{rl:user123}:window) to ensure this.
10. Production-Grade C# Rate Limiter Implementation
A production rate limiter needs more than just the core algorithm. It needs middleware integration, HTTP response headers, metrics export, graceful degradation, and configuration management. Below is a complete ASP.NET Core rate limiting middleware that implements the sliding window counter with Redis backend, proper HTTP headers, and metrics instrumentation. This implementation follows the patterns used in production API gateways at scale.
ASP.NET Core Rate Limiting Middleware
C#
public class RateLimitingMiddleware
{
private readonly RequestDelegate _next;
private readonly DistributedRateLimiter _limiter;
private readonly RateLimitOptions _options;
public RateLimitingMiddleware(
RequestDelegate next,
DistributedRateLimiter limiter,
IOptions<RateLimitOptions> options)
{
_next = next;
_limiter = limiter;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context)
{
string clientKey = ResolveClientKey(context);
var rule = ResolveRule(context.Request.Path);
var result = await _limiter.CheckAsync(
clientKey,
cost: 1,
limit: rule.MaxRequests,
windowSeconds: rule.WindowSeconds);
// Always set rate limit headers
context.Response.Headers["X-RateLimit-Limit"] =
result.Limit.ToString();
context.Response.Headers["X-RateLimit-Remaining"] =
result.Remaining.ToString();
context.Response.Headers["X-RateLimit-Reset"] =
result.ResetAtUnix.ToString();
if (!result.Allowed)
{
context.Response.Headers["Retry-After"] =
result.RetryAfterSeconds.ToString();
context.Response.StatusCode = 429;
var problemDetails = new ProblemDetails
{
Status = 429,
Title = "Too Many Requests",
Detail = $"Rate limit exceeded. " +
$"Retry after " +
$"{result.RetryAfterSeconds} seconds.",
Type =
"https://httpstatuses.com/429"
};
await context.Response.WriteAsJsonAsync(
problemDetails);
return;
}
await _next(context);
}
private string ResolveClientKey(HttpContext context)
{
// Prefer authenticated user ID
string? userId = context.User?
.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userId))
return $"user:{userId}";
// Fall back to API key
string? apiKey = context.Request.Headers
["X-API-Key"].FirstOrDefault();
if (!string.IsNullOrEmpty(apiKey))
return $"apikey:{apiKey}";
// Fall back to IP address
string ip = context.Connection
.RemoteIpAddress?.ToString() ?? "unknown";
return $"ip:{ip}";
}
private RateLimitRule ResolveRule(PathString path)
{
foreach (var rule in _options.Rules)
{
if (path.StartsWithSegments(rule.PathPrefix))
return rule;
}
return _options.DefaultRule;
}
}
public class RateLimitRule
{
public string PathPrefix { get; set; } = "/";
public int MaxRequests { get; set; } = 100;
public int WindowSeconds { get; set; } = 60;
}
public class RateLimitOptions
{
public List<RateLimitRule> Rules { get; set; } = new();
public RateLimitRule DefaultRule { get; set; } = new();
}
Configuration in appsettings.json
JSON
{
"RateLimiting": {
"Rules": [
{
"PathPrefix": "/api/auth",
"MaxRequests": 5,
"WindowSeconds": 300
},
{
"PathPrefix": "/api/payment",
"MaxRequests": 20,
"WindowSeconds": 60
},
{
"PathPrefix": "/api/search",
"MaxRequests": 50,
"WindowSeconds": 10
}
],
"DefaultRule": {
"PathPrefix": "/",
"MaxRequests": 100,
"WindowSeconds": 60
}
}
}
Registration in Program.cs
C#
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<RateLimitOptions>(
builder.Configuration.GetSection("RateLimiting"));
builder.Services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect(
builder.Configuration["Redis:ConnectionString"]!));
builder.Services.AddSingleton<DistributedRateLimiter>();
builder.Services.AddSingleton<RateLimitingMetrics>();
var app = builder.Build();
app.UseMiddleware<RateLimitingMiddleware>();
app.MapControllers();
app.Run();
This implementation provides per-endpoint rate limiting rules, automatic client identification (user ID, API key, or IP address), proper HTTP 429 responses with Retry-After headers, consistent rate limit headers on every response, and fail-open behavior when Redis is unavailable. The configuration is externalized to appsettings.json, making it easy to adjust limits without redeploying code.
11. Rate Limiting in API Gateways and Microservices
In microservices architectures, rate limiting happens at multiple layers. The edge layer (API gateway) applies coarse-grained limits per consumer across all services. The service layer applies fine-grained limits per endpoint within individual microservices. The dependency layer applies outgoing rate limits when calling downstream services. Each layer serves a different purpose and uses different algorithms. Edge limits protect the overall platform. Service limits protect individual resources. Dependency limits protect third-party SLAs and downstream services.
Popular API gateways provide built-in rate limiting. Kong uses the sliding window counter as its default algorithm, with Redis as the backing store. AWS API Gateway provides per-client throttling using a token bucket variant. Azure API Management offers four algorithm options: fixed window, sliding window, liquid bucket (its name for leaky bucket), and concurrent request limiting. NGINX Plus provides rate limiting via the limit_req module, which implements a leaky bucket algorithm with configurable burst and delay. When choosing an API gateway, evaluate its rate limiting capabilities carefully — the algorithm, the backing store, the configuration granularity, and the observability features all matter.
Service-to-Service Rate Limiting
When Service A calls Service B, Service A should respect Service B's rate limits. The recommended pattern is client-side rate limiting: Service A maintains a local rate limiter configured to stay below Service B's known limits. This avoids the latency overhead of checking a centralized rate limiter for every outbound call. Service A uses a token bucket with a capacity equal to Service B's burst limit and a refill rate equal to Service B's sustained limit. If the bucket is empty, Service A queues the request locally and retries after a delay, rather than sending it and getting a 429 response. This is more efficient because it avoids the network round trip for rejected requests.
Circuit Breaker Integration
Rate limiting and circuit breakers are complementary patterns. Rate limiting prevents overload by capping request rates. Circuit breakers detect when a downstream service is unhealthy and stop sending requests entirely. When a circuit breaker is open, all requests fail immediately without hitting the rate limiter. When the circuit transitions to half-open, the rate limiter controls how many probe requests are sent. The combination provides robust protection: rate limiting handles normal traffic management, and circuit breakers handle failure scenarios. In Polly (the .NET resilience library), combine rate limiting with circuit breakers using the ResiliencePipeline builder.
12. Advanced Patterns: Multi-Tier and Hierarchical Limiting
Production rate limiting often requires more than a single global limit per user. Multi-tier rate limiting applies different limits at different levels of granularity. For example, a user might have a limit of 10,000 requests per hour overall, 1,000 requests per hour per endpoint group, and 100 requests per minute per individual endpoint. All three limits must be checked before a request is allowed. If any tier is exceeded, the request is rejected. This prevents a user from concentrating all their traffic on a single expensive endpoint while staying under their global limit.
Multi-Tier Rate Limiter in C#
C#
public class MultiTierRateLimiter
{
private readonly RedisSlidingWindowCounter _global;
private readonly RedisSlidingWindowCounter _group;
private readonly RedisSlidingWindowCounter _endpoint;
public MultiTierRateLimiter(
IConnectionMultiplexer redis)
{
_global = new RedisSlidingWindowCounter(
redis, limit: 10000, windowSeconds: 3600);
_group = new RedisSlidingWindowCounter(
redis, limit: 1000, windowSeconds: 3600);
_endpoint = new RedisSlidingWindowCounter(
redis, limit: 100, windowSeconds: 60);
}
public async Task<RateLimitResult> CheckAllTiersAsync(
string userId, string group, string endpoint)
{
// Check endpoint tier first (cheapest, tightest)
if (!await _endpoint.TryAcquireAsync(
$"{userId}:{group}:{endpoint}"))
{
return RateLimitResult.Denied(
"Endpoint rate limit exceeded");
}
// Check group tier
if (!await _group.TryAcquireAsync(
$"{userId}:{group}"))
{
return RateLimitResult.Denied(
"Group rate limit exceeded");
}
// Check global tier
if (!await _global.TryAcquireAsync(userId))
{
return RateLimitResult.Denied(
"Global rate limit exceeded");
}
return RateLimitResult.Allowed();
}
}
Cost-Based Rate Limiting
Not all API requests consume equal resources. A search query that scans millions of records is far more expensive than a health check endpoint. Cost-based rate limiting assigns a weight (cost) to each request and deducts that cost from the budget rather than deducting a flat 1 per request. For example, a simple GET request might cost 1, a complex search might cost 10, and a bulk operation might cost 50. The rate limiter's TryAcquire method accepts a cost parameter, and the Lua script in Redis uses INCRBY instead of INCR to deduct the appropriate number of tokens.
Named vs Anonymous User Limiting
Different user types deserve different limits. A common pattern is to apply tiered limits based on the user's subscription or authentication status. Anonymous users (identified by IP address) receive the lowest limits (e.g., 60 requests per hour). Authenticated free-tier users receive moderate limits (e.g., 1,000 requests per hour). Paid-tier users receive high limits (e.g., 10,000 requests per hour). Enterprise customers receive custom limits. The rate limiting middleware resolves the user's tier from their authentication token and applies the appropriate rule. This tiered approach ensures fair resource allocation while incentivizing upgrades to paid plans.
| User Tier | Limit | Window | Algorithm |
|---|---|---|---|
| Anonymous (IP-based) | 60 requests | 1 hour | Sliding window counter |
| Free (authenticated) | 1,000 requests | 1 hour | Sliding window counter |
| Pro | 10,000 requests | 1 hour | Token bucket (burst=500) |
| Enterprise | Custom | Custom | Token bucket (custom) |
| Security endpoints (all tiers) | 5 attempts | 5 minutes | Sliding window log |
13. Monitoring, Metrics, and Observability
A rate limiter without observability is a black box that obscures the boundary between protecting your system and silently blocking legitimate users. Every rate limiting implementation should emit detailed metrics that allow you to detect anomalies, tune thresholds, and debug customer issues. The four essential metrics are: total requests checked, allowed requests, denied requests, and per-key denial breakdown. These metrics should be tagged with the client key, endpoint, rule name, and denial reason.
The most important metric is the denial rate — the percentage of requests that receive HTTP 429 responses. A denial rate above 1% for any individual customer is a signal that their limits may be too low or their usage pattern has changed. A sudden spike in denial rates across all customers may indicate a DDoS attack or a misbehaving client. A gradual increase in denial rates may indicate organic growth that requires limit adjustments. Track the denial rate as a time series with per-customer granularity and set alerts on thresholds.
Key Metrics to Track
| Metric | Type | Alert Threshold | Purpose |
|---|---|---|---|
| rate_limit_checks_total | Counter | N/A | Total requests evaluated |
| rate_limit_allowed_total | Counter | N/A | Requests that passed |
| rate_limit_denied_total | Counter | > 5% of checks | Requests rejected with 429 |
| rate_limit_latency_ms | Histogram | p99 > 5ms | Time to evaluate rate limit |
| rate_limit_redis_errors_total | Counter | > 0 in 5 min | Redis connectivity issues |
| rate_limit_fallback_total | Counter | > 0 | Fail-open invocations |
| rate_limit_per_customer_denied | Gauge | > 10% per customer | Per-customer denial rate |
Prometheus and Grafana Integration
C#
public class RateLimitingMetrics
{
private readonly Counter _checksTotal;
private readonly Counter _allowedTotal;
private readonly Counter _deniedTotal;
private readonly Histogram _latencyHistogram;
private readonly Counter _redisErrors;
private readonly Counter _fallbackTotal;
public RateLimitingMetrics(IMetricFactory metrics)
{
_checksTotal = metrics.CreateCounter(
"rate_limit_checks_total",
"Total rate limit checks performed");
_allowedTotal = metrics.CreateCounter(
"rate_limit_allowed_total",
"Total requests allowed by rate limiter");
_deniedTotal = metrics.CreateCounter(
"rate_limit_denied_total",
"Total requests denied by rate limiter");
_latencyHistogram = metrics.CreateHistogram(
"rate_limit_latency_ms",
"Rate limit check latency in milliseconds");
_redisErrors = metrics.CreateCounter(
"rate_limit_redis_errors_total",
"Redis errors during rate limit checks");
_fallbackTotal = metrics.CreateCounter(
"rate_limit_fallback_total",
"Fail-open fallback invocations");
}
public void RecordCheck(
string clientKey, string endpoint,
bool allowed, double latencyMs)
{
_checksTotal.WithLabels(endpoint).Inc();
if (allowed)
_allowedTotal.WithLabels(endpoint).Inc();
else
_deniedTotal.WithLabels(clientKey, endpoint)
.Inc();
_latencyHistogram.WithLabels(endpoint)
.Observe(latencyMs);
}
public void RecordRedisError()
=> _redisErrors.Inc();
public void RecordFallback()
=> _fallbackTotal.Inc();
}
Dashboards should display the overall denial rate, per-customer denial rates, rate limit check latency, Redis error rates, and time-series charts showing traffic patterns relative to configured limits. Use these dashboards to identify customers approaching their limits before they start receiving 429s, detect potential DDoS attacks through unusual traffic patterns, and validate that new rate limiting rules have the intended effect. The metrics data also feeds into capacity planning: if a customer consistently uses 90% of their quota, it is time to discuss an upgrade.
14. Security Considerations and Abuse Prevention
Rate limiting is itself a security mechanism, but it also has security implications that must be addressed. An attacker who can predict or manipulate rate limit keys can bypass limits. An attacker who can exhaust the rate limit store (Redis) can cause denial of service for all users. An attacker who can observe rate limit responses can probe for information about the system's configuration. A secure rate limiting implementation must address all of these vectors.
Key manipulation prevention: Rate limit keys must be derived from authenticated, server-controlled values. Never use client-supplied headers like X-Forwarded-For as the sole rate limit key because attackers can set arbitrary values in these headers to split their traffic across multiple keys. Use the authenticated user ID from verified JWT tokens or API keys. For unauthenticated endpoints, use the IP address from the TCP connection (not the forwarded IP), and accept the limitation that users behind NAT share IP addresses.
Redis security: The Redis instance storing rate limit counters must be protected with authentication, network isolation (VPC or private subnet), and TLS encryption. Without authentication, an attacker who gains network access can delete rate limit keys to bypass all limits. Use Redis ACLs to grant the application only the commands it needs (INCR, GET, SET, EXPIRE, EVAL) and restrict access to other commands. Monitor Redis for unusual patterns such as massive key deletions or unexpected FLUSHALL commands.
Information leakage prevention: Rate limit response headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) reveal information about your system's configuration. While these headers are essential for legitimate clients, they also tell an attacker exactly how many requests they have left. Consider providing detailed headers only to authenticated users and generic "try again later" messages for anonymous requests. Never reveal the exact algorithm, window size, or internal counters in error responses.
- Never trust client-supplied IP addresses or forwarded headers for rate limiting
- Use authenticated user identity (JWT/API key) as the primary rate limit key
- Protect Redis with authentication, network isolation, and TLS
- Use Redis ACLs to restrict available commands
- Implement Redis key expiration to prevent memory exhaustion
- Monitor for anomalous rate limit bypass attempts
- Rate limit the rate limit check endpoint itself to prevent Redis flooding
- Use fail-open behavior for general endpoints, fail-closed for security endpoints
15. Real-World Case Studies
Understanding how major companies implement rate limiting provides practical insights beyond algorithm theory. These case studies illustrate the real-world trade-offs and configurations that production systems use at massive scale.
GitHub — Multi-Layer Rate Limiting
GitHub applies rate limiting at multiple layers. The REST API enforces 5,000 requests per hour for authenticated users and 60 per hour for unauthenticated users, using a sliding window algorithm. GraphQL uses a node-based cost calculation: each query has a calculated cost based on the nodes it requests, and the rate limit is a budget of 5,000 points per hour. GitHub returns X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers on every response. When the limit is exceeded, GitHub returns HTTP 403 (not 429) with a message explaining the rate limit has been exceeded. This quirk illustrates that not all APIs follow the RFC 6585 convention for 429 responses.
Stripe — Tiered Rate Limiting with Idempotency
Stripe's API uses a token bucket rate limiter with per-key limits that vary by endpoint. The most restrictive endpoints (like creating charges) have lower limits than read endpoints. Stripe also implements idempotency keys: a client can send an idempotency key with a request, and Stripe deduplicates requests with the same key within a 24-hour window. This interacts with rate limiting because idempotent requests that are exact duplicates do not count against the rate limit. Stripe's rate limiting is coordinated across their distributed system using a custom in-memory data store rather than Redis, optimized for their specific access patterns and consistency requirements.
Cloudflare — Edge Rate Limiting
Cloudflare processes over 40 million HTTP requests per second across their global network. Their rate limiting operates at the edge, within their data centers distributed across 300+ cities. Cloudflare uses the sliding window counter algorithm, implemented in their Rust-based edge proxy. Rate limit state is stored locally within each data center and periodically synced across data centers. This architecture prioritizes low latency over perfect global accuracy — a request allowed in one data center might be denied in another if state sync is delayed. Cloudflare accepts this trade-off because the alternative (centralized global state) would add unacceptable latency at the edge. Their system processes rate limit decisions in under 1 millisecond.
Twitter (X) — Aggressive Aggressive Rate Limiting
Twitter applies some of the most aggressive rate limits in the industry. Different endpoint categories have wildly different limits: home timeline reads allow 900 requests per 15-minute window, search allows 180 per 15 minutes, direct messages allow 1,000 per 15-minute window, and tweet creation allows 200 per 15-minute window. Twitter uses a fixed window counter with 15-minute windows (not sliding window), which means the boundary burst issue allows up to 2x the stated limit. Twitter accepts this because the 15-minute window is large enough that the boundary effect is relatively small. The 15-minute window also simplifies implementation and reduces Redis key count.
| Company | Algorithm | Window | Backing Store | Key Feature |
|---|---|---|---|---|
| GitHub | Sliding window | 1 hour | Custom distributed store | GraphQL cost-based limiting |
| Stripe | Token bucket | Per-endpoint | Custom in-memory | Idempotency key integration |
| Cloudflare | Sliding window counter | Variable | Local edge + sync | Global edge deployment |
| Fixed window | 15 minutes | Custom distributed store | Per-endpoint-category limits | |
| Google Cloud | Token bucket | Per-second + per-day | Centralized | Two-tier limits (burst + sustained) |
16. Capacity Planning and Performance Benchmarks
Rate limiting infrastructure must be sized to handle your peak request volume with headroom for spikes. The primary bottleneck is the Redis throughput: each rate limit check requires at minimum one EVAL operation (the Lua script) plus potential network round trips. A single Redis instance can handle approximately 100,000-200,000 operations per second on modern hardware. If your API handles 50,000 requests per second and each request triggers one rate limit check, a single Redis instance is sufficient. If you need multi-tier rate limiting (3 checks per request), you need Redis Cluster or multiple Redis instances.
Redis Memory Estimation
| Parameter | Value | Calculation |
|---|---|---|
| Unique rate limit keys | 10 million | Active users across all tiers |
| Window size | 60 seconds | Sliding window counter |
| Bytes per key (sliding window) | ~120 bytes | Key string + 2 counters + overhead |
| Total memory (sliding window) | ~1.2 GB | 10M × 120 bytes |
| Bytes per key (token bucket) | ~90 bytes | Key string + 2 values + overhead |
| Total memory (token bucket) | ~900 MB | 10M × 90 bytes |
| Bytes per key (sliding log, limit=100) | ~900 bytes | Key string + 100 timestamps + overhead |
| Total memory (sliding log) | ~9 GB | 10M × 900 bytes |
Latency Benchmarks
In a production environment with Redis deployed in the same availability zone as the application servers, the typical latency for a rate limit check is 0.5-2 milliseconds for the Redis network round trip, plus 0.1-0.3 milliseconds for the Lua script execution, for a total of 0.6-2.3 milliseconds. This adds negligible latency to a typical API request that takes 10-100 milliseconds for business logic. For geographically distributed deployments, if the Redis instance is in a different region, network latency increases to 50-200 milliseconds, which may be unacceptable. In this case, deploy a Redis replica in each region and accept the slight inconsistency from replication lag.
Sizing Recommendations
- For APIs handling under 10,000 requests per second: single Redis instance with sliding window counter
- For APIs handling 10,000-100,000 requests per second: Redis Cluster with 3-6 nodes, sharding by client key
- For APIs handling over 100,000 requests per second: local-plus-sync pattern with periodic Redis sync, or dedicated rate limiting service
- For global deployments: Redis replica per region with eventual consistency, or edge-local rate limiting with periodic global sync
- For security endpoints: always use centralized Redis for exact accuracy, accept the latency cost
17. Interview Questions and Answers
Q1: Design a rate limiter for a social media API that allows 300 posts per hour per user.
Answer: Use a sliding window counter implemented in Redis. Each user gets a key rl:posts:{user_id} with two counters (current window and previous window). The window size is 3600 seconds. Use a Lua script to atomically check the weighted count and increment the current window counter. Return X-RateLimit headers on every response. For the post endpoint specifically, consider cost-based limiting where photo posts cost 5 tokens and text posts cost 1, because photo uploads consume more server resources. If Redis is down, fail open for posts (allow them) since a few extra posts are less damaging than blocking all posting activity.
Q2: How would you rate limit a WebSocket connection that receives real-time updates?
Answer: WebSocket connections are long-lived, so traditional per-request rate limiting does not apply directly. Instead, limit the message throughput — the number of messages sent or received per second. Apply inbound rate limiting to prevent clients from flooding the server with messages, and apply outbound rate limiting to prevent a single client from consuming all server outbound bandwidth. Use token bucket algorithms with per-connection keys. For subscription-level rate limiting, cap the number of channels a client can subscribe to and the frequency of subscription changes. Monitor connection-level metrics for anomalous message rates that may indicate a misbehaving client.
Q3: You have two data centers and need global rate limiting. How do you handle the consistency challenge?
Answer: There are three approaches with different consistency/latency trade-offs. First, centralized Redis: all rate limit checks go to a single Redis cluster regardless of data center. This provides perfect consistency but adds cross-data-center latency (50-200ms). Second, per-data-center Redis with async replication: each data center has its own Redis replica, and state is replicated asynchronously. Accept that during replication lag (typically 1-10ms), a user can exceed their limit by up to the lag window. Third, hybrid approach: use local counters for coarse per-second limits (low consistency requirement) and central Redis for hourly quotas (high consistency requirement). The hybrid approach provides the best balance for most applications.
Q4: How do you test a rate limiter to ensure it works correctly?
Answer: Rate limiter testing requires careful attention to timing. Unit tests verify the algorithm logic by mocking the clock — send exactly limit requests and verify they all pass, send one more and verify it fails. Integration tests verify the Redis implementation by using a real Redis instance (or Testcontainers) and verifying atomicity under concurrency — spawn 100 parallel tasks each sending 10 requests with a limit of 500, and verify the total allowed is exactly 500. Load tests verify throughput by sending sustained traffic at the configured rate and measuring the denial rate — it should be approximately 0% at the limit and approximately 100% above the limit. Chaos tests verify fail-open behavior by killing Redis and confirming requests are still allowed. Chaos tests also verify boundary behavior by simulating window transitions under load.
Q5: Explain the difference between rate limiting and throttling. When would you use each?
Answer: Rate limiting rejects excess requests immediately with HTTP 429. Throttling delays excess requests by queuing them and processing them at a reduced rate. Use rate limiting when the excess request has no value if delayed — for example, a real-time API call where the result is needed immediately. Use throttling when the request can tolerate delay but should not be dropped — for example, a background data export that the user requested and expects to receive eventually. Many systems implement a hybrid: they rate limit within normal operating parameters and switch to throttling when the system is under extreme load. For example, allow normal traffic at full speed, queue requests between 100-120% of the rate limit, and reject requests above 120%. This provides graceful degradation instead of hard rejection.
Q6: How do you handle rate limiting for batch API endpoints that accept multiple operations in one request?
Answer: Use cost-based rate limiting where each batch request has a cost equal to the number of operations in the batch. A batch endpoint that accepts up to 100 operations should consume up to 100 tokens from the rate limit budget. This prevents a client from bypassing rate limits by batching individual operations. The implementation is straightforward: the rate limiter's TryAcquire method accepts a cost parameter, and the Lua script uses INCRBY instead of INCR. Also enforce a maximum batch size as a separate limit — even if the user has tokens remaining, cap the batch at 100 operations to prevent individual requests from consuming the entire budget.
Q7: Your rate limiter is causing Redis to become a bottleneck. What are your options?
Answer: Several approaches to reduce Redis load. First, local caching: cache rate limit results locally in the application with a short TTL (e.g., 1 second). This reduces Redis load by the cache hit rate (often 90%+) at the cost of allowing slight over-limit tolerance equal to the cache TTL. Second, Redis Cluster sharding: distribute rate limit keys across multiple Redis nodes. Third, local-plus-sync: maintain local counters in each application instance and periodically sync with Redis every 1-5 seconds. This reduces Redis load by 95-99%. Fourth, probabilistic early expiration: instead of setting an exact TTL on Redis keys, use a random TTL that is slightly shorter than the window. This prevents all keys from expiring simultaneously and causing a thundering herd. Fifth, rate limit the rate limiter: apply a very coarse local rate limit (e.g., 100,000 checks per second per instance) before querying Redis. If the local check fails, deny immediately without hitting Redis.
18. Conclusion and Production Checklist
Rate limiting is a foundational capability for any system that exposes an API or serves external traffic. The algorithms we have covered — token bucket, leaky bucket, fixed window counter, sliding window log, and sliding window counter — each have distinct characteristics that make them suitable for different scenarios. The token bucket excels at API rate limiting with burst tolerance. The leaky bucket provides strict output smoothing for outgoing calls. The fixed window counter offers simplicity for coarse quotas. The sliding window log provides perfect accuracy for security-critical endpoints. The sliding window counter offers the best overall balance for production use.
Implementation details matter as much as algorithm selection. Atomic Redis operations via Lua scripts prevent race conditions. Proper HTTP headers enable intelligent client behavior. Fail-open strategies prevent cascading outages. Multi-tier rate limiting provides defense in depth. Cost-based limiting ensures expensive operations are proportionally constrained. Monitoring and alerting catch misconfigured limits before they impact customers. Security hardening prevents attackers from bypassing limits through key manipulation or Redis exploitation.
Key Numbers to Remember
| Metric | Value |
|---|---|
| Redis single-instance throughput | 100K-200K ops/second |
| Rate limit check latency (same AZ) | 0.6-2.3 ms |
| Memory per key (sliding window counter) | ~120 bytes |
| Memory per key (token bucket) | ~90 bytes |
| Memory per key (sliding log, limit=100) | ~900 bytes |
| Acceptable denial rate for legitimate traffic | < 1% |
| Cache TTL for local caching | 0.5-2 seconds |
| Recommended window size for API limiting | 60 seconds |
Production Deployment Checklist
- Choose algorithm based on the decision matrix in Section 8
- Implement atomic Redis operations via Lua scripts — never use non-atomic GET-then-SET patterns
- Return HTTP 429 with Retry-After header when limits are exceeded
- Return X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset on every response
- Configure per-endpoint rules with tighter limits for sensitive endpoints (auth, payment, OTP)
- Implement fail-open for general endpoints and fail-closed for security endpoints
- Deploy Redis with authentication, network isolation, TLS, and ACLs
- Set Redis key TTLs to twice the window duration to prevent memory leaks
- Add Prometheus metrics: checks, allows, denials, latency, Redis errors, fallbacks
- Create Grafana dashboards with per-customer denial rate tracking
- Set alerts on denial rate exceeding 5% and Redis error rate exceeding 0
- Implement local caching for high-throughput endpoints to reduce Redis load
- Test atomicity under concurrency with parallel task testing
- Chaos test fail-open behavior by killing Redis in staging
- Document rate limits in API documentation with examples and response headers
- Review and adjust limits quarterly based on usage patterns and customer feedback
Common Interview Mistakes to Avoid
- Recommending the fixed window counter without acknowledging the boundary burst problem
- Forgetting to discuss distributed coordination — a single-server rate limiter is not production-ready
- Not mentioning atomic Redis operations — the non-atomic GET-then-INCR pattern has race conditions
- Ignoring fail-open vs fail-closed trade-offs — every rate limiter needs a degradation strategy
- Choosing the sliding window log for high-traffic endpoints without discussing memory cost
- Forgetting HTTP headers — rate limit headers are essential for client integration
- Not discussing monitoring — a rate limiter without observability cannot be tuned or debugged
- Conflating rate limiting with throttling — they are different mechanisms with different use cases
Rate limiting is not a set-and-forget configuration. Traffic patterns change, customer needs evolve, and new attack vectors emerge. Build your rate limiting infrastructure with observability, configurability, and testability from day one. The investment in a well-designed rate limiting system pays dividends in system reliability, customer trust, and operational confidence. Whether you are building a simple REST API or a global platform serving billions of requests per day, the principles in this guide provide the foundation for a rate limiting strategy that protects your infrastructure while serving your users fairly.