system-design48 min read

Rate Limiting Algorithms: The Complete Guide — A Senior+ Guide | Ayodhyya

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.

Senior+ Guide 50+ min read 10,000+ words Ayodhyya

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#.

Interview Context: Rate limiting is one of the most frequently tested system design topics. Interviewers want to see that you understand the trade-offs between different algorithms, can reason about memory and accuracy, and know how to implement rate limiting in a distributed environment. This guide covers everything you need to ace that discussion.

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.

PropertyDescriptionWhy It Matters
AccuracyHow closely enforced rate matches configured limitInaccurate limiters either block legitimate traffic or allow abuse
MemoryBytes required per tracked keyAt scale, memory determines Redis cluster size and cost
Burst toleranceWhether traffic spikes are allowedAPIs need bursts; background processing needs smoothness
Distributed supportCoordination across multiple instancesEssential 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.

Gotcha: The token bucket does not enforce a strict constant rate. If the bucket is full, a burst equal to the full capacity is allowed. If you need strict rate smoothing (for example, when calling a third-party API with a strict per-second limit), use the leaky bucket instead. Mixing up these two algorithms is one of the most common mistakes in rate limiter implementations.

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.

Key Insight: The leaky bucket and token bucket are complementary, not competing algorithms. Use token bucket for incoming API rate limiting (where bursts from legitimate users are acceptable) and leaky bucket for outgoing API rate limiting (where you must not exceed the remote service's rate). A well-designed system often uses both at different points in its request lifecycle.

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

flowchart TD A[Need Rate Limiting] --> B{Need perfect accuracy?} B -->|Yes, security-critical| C{Low limit per window?} C -->|Yes, under 100| D[Sliding Window Log] C -->|No, high limit| E[Sliding Window Counter with Redis] B -->|No, slight error acceptable| F{Need burst tolerance?} F -->|Yes, API rate limiting| G[Token Bucket] F -->|No, strict constant rate| H{Outgoing API calls?} H -->|Yes| I[Leaky Bucket] H -->|No, general internal limiting| J{Coarse limit?} J -->|Yes, daily or hourly| K[Fixed Window Counter] J -->|No, per-second or per-minute| L[Sliding Window Counter]

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.

Recommendation: Start with the sliding window counter for all new APIs. It provides the best balance of accuracy, memory, and simplicity. Use the token bucket when you specifically need burst tolerance (for example, a search API where users issue batch queries). Use the leaky bucket only for outgoing API calls to third-party services. Reserve the sliding window log for security-critical endpoints with tight limits. Avoid the fixed window counter for any user-facing API where boundary bursts are problematic.

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.

Warning: Redis Cluster introduces key migration during resharding. During migration, rate limit operations may fail or return stale data. Implement retry logic with exponential backoff for rate limit checks, and consider the fail-open strategy during cluster operations. Also note that Lua scripts in Redis Cluster must operate on keys within the same slot — use hash tags (e.g., {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.

Architecture Pattern: In a typical microservices deployment, the edge gateway applies per-consumer limits (e.g., 1000 req/s per API key), each service applies per-endpoint limits (e.g., 100 req/s for the search endpoint), and each service applies outgoing limits for downstream calls (e.g., 50 req/s to the payment provider). This three-tier approach provides defense in depth: even if one layer is misconfigured, the other layers provide protection.

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 TierLimitWindowAlgorithm
Anonymous (IP-based)60 requests1 hourSliding window counter
Free (authenticated)1,000 requests1 hourSliding window counter
Pro10,000 requests1 hourToken bucket (burst=500)
EnterpriseCustomCustomToken bucket (custom)
Security endpoints (all tiers)5 attempts5 minutesSliding 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

MetricTypeAlert ThresholdPurpose
rate_limit_checks_totalCounterN/ATotal requests evaluated
rate_limit_allowed_totalCounterN/ARequests that passed
rate_limit_denied_totalCounter> 5% of checksRequests rejected with 429
rate_limit_latency_msHistogramp99 > 5msTime to evaluate rate limit
rate_limit_redis_errors_totalCounter> 0 in 5 minRedis connectivity issues
rate_limit_fallback_totalCounter> 0Fail-open invocations
rate_limit_per_customer_deniedGauge> 10% per customerPer-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.

Security Checklist:
  • 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.

CompanyAlgorithmWindowBacking StoreKey Feature
GitHubSliding window1 hourCustom distributed storeGraphQL cost-based limiting
StripeToken bucketPer-endpointCustom in-memoryIdempotency key integration
CloudflareSliding window counterVariableLocal edge + syncGlobal edge deployment
TwitterFixed window15 minutesCustom distributed storePer-endpoint-category limits
Google CloudToken bucketPer-second + per-dayCentralizedTwo-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

ParameterValueCalculation
Unique rate limit keys10 millionActive users across all tiers
Window size60 secondsSliding window counter
Bytes per key (sliding window)~120 bytesKey string + 2 counters + overhead
Total memory (sliding window)~1.2 GB10M × 120 bytes
Bytes per key (token bucket)~90 bytesKey string + 2 values + overhead
Total memory (token bucket)~900 MB10M × 90 bytes
Bytes per key (sliding log, limit=100)~900 bytesKey string + 100 timestamps + overhead
Total memory (sliding log)~9 GB10M × 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.

Interview Tip: When answering rate limiting interview questions, always cover these five areas: (1) which algorithm and why, (2) the data store and why, (3) how it works in a distributed environment, (4) how errors are handled (fail-open vs fail-closed), and (5) how it is monitored. Touching all five areas demonstrates production thinking and distinguishes senior candidates from junior ones.

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

MetricValue
Redis single-instance throughput100K-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 caching0.5-2 seconds
Recommended window size for API limiting60 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.

Ayodhyya — System Design Blog Series

Rate Limiting Algorithms: The Complete Guide — Senior+ Guide

Originally published on Ayodhyya. Last updated June 15, 2026.