system-design36 min read

Design a Distributed API Rate Limiter - The System Design Codex

Design a Distributed API Rate Limiter

A deep-dive into rate limiting algorithms, distributed coordination, and production-grade implementation with C# examples.

Last updated: July 2025 | Reading time: ~35 minutes | Words: 11,000+

1. Introduction & Motivation

Every public-facing API needs rate limiting. Without it, a single misbehaving client can starve resources for all other users, trigger cascading failures, and run up infrastructure costs. Rate limiting is the first line of defense in building resilient, fair, and cost-effective distributed systems.

Consider these scenarios: a mobile app client hitting your API 10,000 times per second due to a bug, a DDoS attack flooding your endpoints, or a batch job consuming all available database connections. Rate limiting protects against all of these. But implementing rate limiting correctly in a distributed environment — where multiple API gateway nodes must coordinate to enforce a single global limit — is a genuinely hard problem.

This article covers every algorithm (fixed window, sliding window log, sliding window counter, token bucket, leaky bucket), distributed coordination strategies (Redis-based, Lua scripting, consensus-based), and production considerations like multi-tenancy, tiered limits, graceful degradation, and observability. We provide complete C# implementations throughout, alongside the language-agnostic system design discussion.

Why This Matters for Interviews

Rate limiter design tests your understanding of distributed coordination, algorithm trade-offs, system reliability, and API design. It is one of the most commonly asked system design questions at FAANG companies because it combines theoretical algorithm knowledge with practical distributed systems engineering.

2. Interview Context

Why Interviewers Ask This

  • Algorithmic thinking: Multiple rate limiting algorithms exist, each with different trade-offs. Can you explain the differences?
  • Distributed coordination: How do you enforce a global rate limit across multiple API gateway nodes?
  • Trade-off analysis: Accuracy vs performance, memory vs precision, local vs global limits.
  • Production awareness: Multi-tenancy, tiered limits, graceful degradation, and observability.
  • API design: How do you expose rate limit headers and handle limit violations?

What Interviewers Look For

LevelExpectation
JuniorKnows what rate limiting is, can explain token bucket
Mid-levelCan compare algorithms, implement single-node rate limiter
SeniorCan design distributed rate limiting, handle edge cases
Staff+Multi-tenancy, tiered limits, graceful degradation, production hardening

3. Functional Requirements

Core Features

  • Rate limiting by API key: Each client gets a per-second and per-minute limit
  • Rate limiting by endpoint: Different endpoints can have different limits
  • Tiered limits: Free tier (100 req/min), Pro tier (1000 req/min), Enterprise (10000 req/min)
  • Graceful rejection: Return HTTP 429 with Retry-After header
  • Rate limit headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
  • Admin dashboard: View and modify rate limits per client

Use Cases

ActorActionExpected Outcome
API ClientSend request within limitRequest processed normally
API ClientSend request exceeding limitHTTP 429 with Retry-After header
AdminView rate limit statsDashboard shows usage per client
AdminUpdate client tierClient gets new limits immediately

4. Non-Functional Requirements

RequirementTargetJustification
Latency< 1ms for rate limit checkMust not add significant overhead to API request path
Availability99.99%Rate limiter is in the critical path; outage means no API access
AccuracyWithin 1% of configured limitsApproximate counting is acceptable for most use cases
Throughput100K+ checks/second per nodeMust handle peak API traffic
Memory< 1GB for 1M active clientsEfficient storage of rate limit counters
Fault ToleranceFail open (allow requests) on limiter failureRate limiter failure should not block all traffic
Multi-tenancySupport 10K+ tenantsSaaS platform serving multiple customers
Critical Design Decision: When the rate limiter itself fails, should we fail open (allow all traffic) or fail closed (block all traffic)? For most APIs, fail open is the correct choice — a brief period of unlimited traffic is better than a complete outage. The exception is payment or security-critical endpoints where fail-closed is appropriate.

5. Requirement Prioritization

MoSCoW Analysis

PriorityRequirementRationale
Must HavePer-client rate limiting with configurable limitsCore functionality
Must HaveDistributed rate limiting across multiple nodesCannot work in distributed API gateway without this
Must HaveHTTP 429 responses with standard headersAPI contract compliance
Must HaveFail-open behavior when limiter is downPrevent self-inflicted outage
Should HaveTiered limits (Free/Pro/Enterprise)Business requirement for monetization
Should HavePer-endpoint rate limitsDifferent endpoints have different costs
Should HaveReal-time usage dashboardOperational visibility
Could HaveDynamic rate adjustment based on system loadGraceful degradation under pressure
Could HaveRate limit bypass for trusted clientsInternal services may need unlimited access

6. Capacity Estimation

Throughput

MetricValueCalculation
Total API requests/sec50,000 req/sGiven
Active API clients100,000Given
Rate limit checks/sec50,000One check per request
Unique keys per second~10,00050K requests / 5 avg per client

Storage

DataSize per entryTotal entriesTotal storage
Sliding window counter64 bytes100K clients6.4 MB
Token bucket state32 bytes100K clients3.2 MB
Client config (tier, limits)128 bytes100K clients12.8 MB
Total~22 MB

Bandwidth

DirectionBytes/requestRequests/secBandwidth
Rate limit check (Redis)20050,00010 MB/s
Rate limit response headers12050,0006 MB/s
Total16 MB/s
'@ Add-Content -Path "D:\10blogs\api-rate-limiter.html" -Value $a -Encoding UTF8

7. Rate Limiting Algorithms

The choice of algorithm is the most fundamental decision in rate limiter design. Each algorithm has different trade-offs in terms of accuracy, memory usage, burst tolerance, and implementation complexity.

Algorithm 1: Fixed Window Counter

The simplest approach: divide time into fixed windows (e.g., 1-minute intervals) and count requests in each window. Reject when count exceeds the limit.

Fixed Window Counter

graph LR subgraph "Window: 12:00-12:01" W1[Count: 0] --> W2[Count: 50] --> W3[Count: 100
LIMIT] end subgraph "Window: 12:01-12:02" W4[Count: 0
Reset] --> W5[Count: 30] --> W6[Count: 80] end W3 -->|"Time crosses boundary"| W4 style W3 fill:#ff4444 style W6 fill:#4caf50
public class FixedWindowRateLimiter
{
    private readonly ConcurrentDictionary<string, long> _counters = new();
    private readonly int _limit;
    private readonly TimeSpan _window;

    public FixedWindowRateLimiter(int limit, TimeSpan window)
    {
        _limit = limit;
        _window = window;
    }

    public RateLimitResult Allow(string key)
    {
        var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var windowKey = $"{key}:{now / (long)_window.TotalSeconds}";

        var count = _counters.AddOrUpdate(windowKey, 1, (k, v) => v + 1);

        var resetTime = ((now / (long)_window.TotalSeconds) + 1) * (long)_window.TotalSeconds;

        return new RateLimitResult
        {
            Allowed = count <= _limit,
            Remaining = Math.Max(0, _limit - (int)count),
            Limit = _limit,
            ResetAt = DateTimeOffset.FromUnixTimeSeconds(resetTime),
            RetryAfter = count > _limit
                ? TimeSpan.FromSeconds(resetTime - now)
                : TimeSpan.Zero
        };
    }
}

Pros: Simple, O(1) memory, O(1) time. Cons: Boundary burst problem — a client can send 100 requests at 12:00:59 and 100 more at 12:01:00, effectively getting 200 requests in 1 second.

Algorithm 2: Sliding Window Log

Store timestamps of every request in a sorted set. Count requests in the last N seconds. Eliminates the boundary problem but costs more memory.

public class SlidingWindowLogRateLimiter
{
    private readonly ConcurrentDictionary<string, SortedList<long, int>> _logs = new();
    private readonly int _limit;
    private readonly TimeSpan _window;

    public SlidingWindowLogRateLimiter(int limit, TimeSpan window)
    {
        _limit = limit;
        _window = window;
    }

    public RateLimitResult Allow(string key)
    {
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        var windowStart = now - (long)_window.TotalMilliseconds;

        var log = _logs.GetOrAdd(key, _ => new SortedList<long, int>());

        lock (log)
        {
            // Remove entries outside the window
            var keysToRemove = log.Keys.Where(k => k <= windowStart).ToList();
            foreach (var k in keysToRemove)
                log.Remove(k);

            var currentCount = log.Count;

            if (currentCount >= _limit)
            {
                var oldestEntry = log.Keys.First();
                var retryAfter = TimeSpan.FromMilliseconds(oldestEntry + (long)_window.TotalMilliseconds - now);

                return new RateLimitResult
                {
                    Allowed = false,
                    Remaining = 0,
                    Limit = _limit,
                    RetryAfter = retryAfter
                };
            }

            log.Add(now, currentCount + 1);

            return new RateLimitResult
            {
                Allowed = true,
                Remaining = _limit - currentCount - 1,
                Limit = _limit,
                RetryAfter = TimeSpan.Zero
            };
        }
    }
}

Pros: Exact counting, no boundary problem. Cons: O(N) memory per client (N = limit), lock contention under high concurrency.

Algorithm 3: Sliding Window Counter (Recommended)

A hybrid approach: use the current window count plus a weighted portion of the previous window. Best balance of accuracy and performance.

Sliding Window Counter Calculation

graph TB A["Current Time: 12:30:15"] --> B["Current Window (12:30-12:31)
Count: 45 requests"] A --> C["Previous Window (12:29-12:30)
Count: 80 requests"] A --> D["Weight Calculation
15 seconds into current window
Weight = 15/60 = 0.25"] B --> E["Estimated Count = 80 × (1-0.25) + 45
= 60 + 45 = 105"] C --> E D --> E E --> F{"105 >= Limit (100)?"} F -->|Yes| G[REJECT] F -->|No| G2[ALLOW] style G fill:#ff4444 style G2 fill:#4caf50
public class SlidingWindowCounterRateLimiter
{
    private readonly ConcurrentDictionary<string, WindowBucket> _windows = new();
    private readonly int _limit;
    private readonly TimeSpan _window;

    public SlidingWindowCounterRateLimiter(int limit, TimeSpan window)
    {
        _limit = limit;
        _window = window;
    }

    public RateLimitResult Allow(string key)
    {
        var now = DateTimeOffset.UtcNow;
        var currentWindowStart = now - now.TimeOfDay
            .Subtract(now.TimeOfDay)
            .Add(TimeSpan.FromSeconds(
                (long)(now - DateTimeOffset.UnixEpoch).TotalSeconds
                / (long)_window.TotalSeconds * (long)_window.TotalSeconds));

        var windowSeconds = (long)_window.TotalSeconds;
        var nowSeconds = (long)(now - DateTimeOffset.UnixEpoch).TotalSeconds;
        var currentWindowId = nowSeconds / windowSeconds;
        var previousWindowId = currentWindowId - 1;
        var positionInWindow = nowSeconds % windowSeconds;
        var weight = 1.0 - ((double)positionInWindow / windowSeconds);

        var bucket = _windows.AddOrUpdate(key,
            _ => new WindowBucket { Current = 0, Previous = 0, CurrentWindowId = currentWindowId },
            (k, existing) =>
            {
                if (existing.CurrentWindowId == currentWindowId)
                    return existing;

                return new WindowBucket
                {
                    Previous = existing.CurrentWindowId == previousWindowId ? existing.Current : 0,
                    Current = 0,
                    CurrentWindowId = currentWindowId
                };
            });

        long count;
        lock (bucket)
        {
            if (bucket.CurrentWindowId == currentWindowId)
                bucket.Current++;
            else
            {
                bucket.Previous = bucket.Current;
                bucket.Current = 1;
                bucket.CurrentWindowId = currentWindowId;
            }
            count = (long)(bucket.Previous * weight) + bucket.Current;
        }

        var resetAt = DateTimeOffset.FromUnixTimeSeconds((currentWindowId + 1) * windowSeconds);

        return new RateLimitResult
        {
            Allowed = count <= _limit,
            Remaining = Math.Max(0, _limit - (int)count),
            Limit = _limit,
            ResetAt = resetAt,
            RetryAfter = count > _limit
                ? resetAt - now
                : TimeSpan.Zero
        };
    }
}

public class WindowBucket
{
    public long Previous { get; set; }
    public long Current { get; set; }
    public long CurrentWindowId { get; set; }
}

Pros: Good accuracy (~99%), O(1) memory, no boundary problem. Cons: Approximate (weighted interpolation is an estimate).

Algorithm 4: Token Bucket

A bucket holds tokens. Each request consumes one token. Tokens are added at a fixed rate. Allows bursts up to bucket capacity, then throttles to the refill rate.

Token Bucket

graph TB R[Token Refiller
10 tokens/sec] --> B["Token Bucket
Capacity: 100"] B --> D{"Request arrives"} D -->|"Tokens available"| E[Consume 1 token
Allow request] D -->|"Bucket empty"| F[Reject request
HTTP 429] E --> G["Remaining: 99"] F --> H["Retry after: 100ms"] style B fill:#0099ff style E fill:#4caf50 style F fill:#ff4444
public class TokenBucketRateLimiter
{
    private readonly ConcurrentDictionary<string, TokenBucket> _buckets = new();
    private readonly int _capacity;
    private readonly double _refillRate; // tokens per second

    public TokenBucketRateLimiter(int capacity, int refillRatePerSecond)
    {
        _capacity = capacity;
        _refillRate = refillRatePerSecond;
    }

    public RateLimitResult Allow(string key)
    {
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

        var bucket = _buckets.GetOrAdd(key, _ => new TokenBucket
        {
            Tokens = _capacity,
            LastRefillTime = now
        });

        lock (bucket)
        {
            // Refill tokens
            var elapsed = (now - bucket.LastRefillTime) / 1000.0;
            var tokensToAdd = elapsed * _refillRate;
            bucket.Tokens = Math.Min(_capacity, bucket.Tokens + tokensToAdd);
            bucket.LastRefillTime = now;

            if (bucket.Tokens >= 1)
            {
                bucket.Tokens -= 1;
                return new RateLimitResult
                {
                    Allowed = true,
                    Remaining = (int)bucket.Tokens,
                    Limit = _capacity,
                    RetryAfter = TimeSpan.Zero
                };
            }
            else
            {
                var waitTime = (1 - bucket.Tokens) / _refillRate;
                return new RateLimitResult
                {
                    Allowed = false,
                    Remaining = 0,
                    Limit = _capacity,
                    RetryAfter = TimeSpan.FromSeconds(waitTime)
                };
            }
        }
    }
}

public class TokenBucket
{
    public double Tokens { get; set; }
    public long LastRefillTime { get; set; }
}

Pros: Allows controlled bursts, smooth rate over time. Cons: More complex, floating-point precision issues, requires lock for thread safety.

Algorithm 5: Leaky Bucket

Requests enter a queue (bucket). They are processed at a fixed rate. If the queue is full, new requests are rejected. Provides smooth, predictable output rate.

public class LeakyBucketRateLimiter
{
    private readonly ConcurrentDictionary<string, LeakyBucket> _buckets = new();
    private readonly int _capacity;
    private readonly double _leakRate; // requests per second

    public LeakyBucketRateLimiter(int capacity, int leakRatePerSecond)
    {
        _capacity = capacity;
        _leakRate = leakRatePerSecond;
    }

    public RateLimitResult Allow(string key)
    {
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

        var bucket = _buckets.GetOrAdd(key, _ => new LeakyBucket
        {
            Water = 0,
            LastLeakTime = now
        });

        lock (bucket)
        {
            // Leak water (process queued requests)
            var elapsed = (now - bucket.LastLeakTime) / 1000.0;
            var leaked = elapsed * _leakRate;
            bucket.Water = Math.Max(0, bucket.Water - leaked);
            bucket.LastLeakTime = now;

            if (bucket.Water + 1 <= _capacity)
            {
                bucket.Water += 1;
                return new RateLimitResult
                {
                    Allowed = true,
                    Remaining = (int)(_capacity - bucket.Water),
                    Limit = _capacity,
                    RetryAfter = TimeSpan.Zero
                };
            }
            else
            {
                var waitTime = (bucket.Water + 1 - _capacity) / _leakRate;
                return new RateLimitResult
                {
                    Allowed = false,
                    Remaining = 0,
                    Limit = _capacity,
                    RetryAfter = TimeSpan.FromSeconds(waitTime)
                };
            }
        }
    }
}

public class LeakyBucket
{
    public double Water { get; set; }
    public long LastLeakTime { get; set; }
}

Pros: Smooth output rate, protects downstream services. Cons: Adds latency (queued requests wait), doesn't handle bursts well.

Algorithm Comparison

AlgorithmMemoryAccuracyBurst ToleranceComplexityBest For
Fixed WindowO(1)Low (boundary issue)HighSimpleSimple use cases, rough limiting
Sliding Window LogO(N)ExactNoneMediumExact counting, low-traffic APIs
Sliding Window CounterO(1)High (~99%)LowMediumGeneral purpose (recommended)
Token BucketO(1)HighConfigurableMediumAPIs that need burst tolerance
Leaky BucketO(1)HighNone (smooth)MediumProtecting downstream services

8. High-Level Architecture

API Rate Limiter Architecture

graph TB subgraph "Client Layer" C1[Web App] --> LB[Load Balancer] C2[Mobile App] --> LB C3[3rd Party API] --> LB end subgraph "API Gateway Layer" LB --> GW1[API Gateway 1] LB --> GW2[API Gateway 2] LB --> GW3[API Gateway N] end subgraph "Rate Limiting Layer" GW1 --> RL1[Rate Limiter Middleware] GW2 --> RL2[Rate Limiter Middleware] GW3 --> RL3[Rate Limiter Middleware] end subgraph "State Store" RL1 --> Redis[(Redis Cluster)] RL2 --> Redis RL3 --> Redis end subgraph "Configuration" RL1 --> Config[(Rate Limit Config Store)] Config --> Admin[Admin Dashboard] end RL1 -->|"Allow"| BE[Backend Services] RL1 -->|"Reject 429"| C1 style Redis fill:#ff6b35 style Config fill:#0099ff

Key Components

  • Rate Limiter Middleware: Intercepts every API request before it reaches the backend. Checks rate limits and either allows or rejects.
  • Redis Cluster: Stores rate limit counters with sub-millisecond access. Provides atomic operations for concurrent access.
  • Configuration Store: Stores per-client and per-endpoint rate limit configurations. Supports dynamic updates.
  • Admin Dashboard: Allows operators to view usage, modify limits, and manage client tiers.

9. Production Architecture Diagram

Request Flow with Rate Limiting

API Request Rate Limit Check Flow

sequenceDiagram participant Client participant GW as API Gateway participant RL as Rate Limiter participant Redis as Redis Cluster participant Config as Config Store participant Backend as Backend Service Client->>GW: POST /api/v1/orders GW->>RL: Check rate limit (api_key, endpoint) RL->>Config: Get limits for api_key Config-->>RL: {limit: 1000, window: 60s} RL->>Redis: INCR + EXPIRE (sliding window) Redis-->>RL: count=142, ttl=45s RL->>RL: Is 142 >= 1000? No RL->>Redis: Add response headers RL-->>GW: ALLOW (remaining: 858) GW->>GW: Add X-RateLimit-* headers GW->>Backend: Forward request Backend-->>GW: 200 OK + response GW-->>Client: 200 OK + rate limit headers

Rejection Flow

Rate Limit Exceeded Rejection

sequenceDiagram participant Client participant GW as API Gateway participant RL as Rate Limiter participant Redis as Redis Cluster Client->>GW: POST /api/v1/orders GW->>RL: Check rate limit RL->>Redis: INCR + EXPIRE Redis-->>RL: count=1001 RL->>RL: Is 1001 >= 1000? Yes RL->>Redis: Calculate retry-after (TTL) Redis-->>RL: ttl=12s RL-->>GW: REJECT (retry-after: 12s) GW-->>Client: 429 Too Many Requests Note over Client: Headers: Retry-After: 12
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705312860

10. Component Deep Dive

Redis-Based Sliding Window with Lua Script

The core rate limiting logic runs as a single atomic Lua script inside Redis, eliminating race conditions and network round-trips.

public class RedisSlidingWindowRateLimiter
{
    private readonly IConnectionMultiplexer _redis;
    private readonly ILogger<RedisSlidingWindowRateLimiter> _logger;

    private static readonly string RateLimitScript = @"
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local window = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])

        local window_start = now - window

        -- Remove entries outside the window
        redis.call('ZREMRANGEBYSCORE', key, 0, window_start)

        -- Count requests in current window
        local current = redis.call('ZCARD', key)

        if current < limit then
            -- Add current request
            redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
            redis.call('EXPIRE', key, window)
            return {1, limit - current - 1, 0}
        else
            -- Calculate retry-after from oldest entry in window
            local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
            local retry_after = 0
            if #oldest > 0 then
                retry_after = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
            end
            return {0, 0, retry_after}
        end
    ";

    public RedisSlidingWindowRateLimiter(IConnectionMultiplexer redis,
        ILogger<RedisSlidingWindowRateLimiter> logger)
    {
        _redis = redis;
        _logger = logger;
    }

    public async Task<RateLimitResult> CheckAsync(string key, int limit, TimeSpan window)
    {
        var db = _redis.GetDatabase();
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

        var result = await db.ScriptEvaluateAsync(
            RateLimitScript,
            new RedisKey[] { $"ratelimit:{key}" },
            new RedisValue[] { now, (long)window.TotalMilliseconds, limit });

        var values = (long[])result;
        var allowed = values[0] == 1;
        var remaining = (int)values[1];
        var retryAfter = (int)values[2];

        _logger.LogDebug("Rate limit check for {Key}: {Allowed}, remaining: {Remaining}",
            key, allowed, remaining);

        return new RateLimitResult
        {
            Allowed = allowed,
            Remaining = remaining,
            Limit = limit,
            RetryAfter = TimeSpan.FromSeconds(retryAfter)
        };
    }
}

Distributed Token Bucket with Redis

public class DistributedTokenBucket
{
    private readonly IConnectionMultiplexer _redis;

    private static readonly string TokenBucketScript = @"
        local key = KEYS[1]
        local capacity = tonumber(ARGV[1])
        local refill_rate = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])
        local requested = tonumber(ARGV[4])

        local state = redis.call('HMGET', key, 'tokens', 'last_refill')
        local tokens = tonumber(state[1]) or capacity
        local last_refill = tonumber(state[2]) or now

        -- Refill tokens
        local elapsed = (now - last_refill) / 1000.0
        local refill = elapsed * refill_rate
        tokens = math.min(capacity, tokens + refill)
        last_refill = now

        if tokens >= requested then
            tokens = tokens - requested
            redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
            redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
            return {1, math.floor(tokens)}
        else
            local wait_ms = ((requested - tokens) / refill_rate) * 1000
            redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
            return {0, 0, math.ceil(wait_ms)}
        end
    ";

    public DistributedTokenBucket(IConnectionMultiplexer redis)
    {
        _redis = redis;
    }

    public async Task<RateLimitResult> AllowAsync(string key, int capacity,
        int refillRatePerSecond, int tokensRequested = 1)
    {
        var db = _redis.GetDatabase();
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

        var result = await db.ScriptEvaluateAsync(
            TokenBucketScript,
            new RedisKey[] { $"tokenbucket:{key}" },
            new RedisValue[] { capacity, refillRatePerSecond, now, tokensRequested });

        var values = (long[])result;
        return new RateLimitResult
        {
            Allowed = values[0] == 1,
            Remaining = values.Length > 1 ? (int)values[1] : 0,
            Limit = capacity,
            RetryAfter = values.Length > 2
                ? TimeSpan.FromMilliseconds(values[2])
                : TimeSpan.Zero
        };
    }
}

Multi-Tier Rate Limiter

public class MultiTierRateLimiter
{
    private readonly RedisSlidingWindowRateLimiter _redisLimiter;
    private readonly ConcurrentDictionary<string, ClientTierConfig> _tierCache = new();
    private readonly IConfigurationStore _configStore;

    public async Task<RateLimitResult> CheckAsync(string apiKey, string endpoint)
    {
        var config = await GetTierConfig(apiKey);

        // Check per-second limit first (fast path)
        var secondKey = $"{apiKey}:s";
        var secondResult = await _redisLimiter.CheckAsync(
            secondKey, config.PerSecondLimit, TimeSpan.FromSeconds(1));

        if (!secondResult.Allowed)
            return secondResult;

        // Then check per-minute limit
        var minuteKey = $"{apiKey}:m";
        var minuteResult = await _redisLimiter.CheckAsync(
            minuteKey, config.PerMinuteLimit, TimeSpan.FromSeconds(60));

        if (!minuteResult.Allowed)
            return minuteResult;

        // Then check per-hour limit
        var hourKey = $"{apiKey}:h";
        var hourResult = await _redisLimiter.CheckAsync(
            hourKey, config.PerHourLimit, TimeSpan.FromSeconds(3600));

        return new RateLimitResult
        {
            Allowed = true,
            Remaining = Math.Min(
                Math.Min(secondResult.Remaining, minuteResult.Remaining),
                hourResult.Remaining),
            Limit = config.PerMinuteLimit,
            RetryAfter = TimeSpan.Zero
        };
    }

    private async Task<ClientTierConfig> GetTierConfig(string apiKey)
    {
        if (_tierCache.TryGetValue(apiKey, out var cached))
            return cached;

        var config = await _configStore.GetClientConfig(apiKey);
        _tierCache[apiKey] = config;
        return config;
    }
}

public class ClientTierConfig
{
    public string ApiKey { get; set; }
    public string Tier { get; set; }  // "free", "pro", "enterprise"
    public int PerSecondLimit { get; set; }
    public int PerMinuteLimit { get; set; }
    public int PerHourLimit { get; set; }
    public List<string> BypassEndpoints { get; set; } = new();
}

11. Data Modeling

Redis Data Structures

Key PatternTypeValueTTL
ratelimit:{api_key}:sSorted SetRequest timestamps as members2 seconds
ratelimit:{api_key}:mSorted SetRequest timestamps as members2 minutes
tokenbucket:{api_key}Hash{tokens: float, last_refill: long}2 × refill time
config:{api_key}Hash{tier, per_sec, per_min, per_hour}No expiry

Configuration Store Schema

public class RateLimitConfig
{
    public string Id { get; set; }
    public string ApiKey { get; set; }
    public string ClientName { get; set; }
    public string Tier { get; set; }

    // Global limits
    public int GlobalPerSecond { get; set; } = 10;
    public int GlobalPerMinute { get; set; } = 100;
    public int GlobalPerHour { get; set; } = 10000;

    // Per-endpoint overrides
    public Dictionary<string, EndpointLimit> EndpointLimits { get; set; } = new();

    // Metadata
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
    public bool IsActive { get; set; } = true;
}

public class EndpointLimit
{
    public string Pattern { get; set; }  // "/api/v1/orders"
    public int PerSecond { get; set; }
    public int PerMinute { get; set; }
    public bool IsBypassed { get; set; }  // No rate limiting
}

12. API Design

HTTP Response Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 742
X-RateLimit-Reset: 1705312860
X-RateLimit-Policy: 1000;w=60

Content-Type: application/json

{"status": "success", "data": {...}}

Rate Limit Exceeded Response

HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705312860
Content-Type: application/json

{
    "error": {
        "code": "RATE_LIMIT_EXCEEDED",
        "message": "Rate limit exceeded. Try again in 12 seconds.",
        "details": {
            "limit": 1000,
            "window": "60s",
            "retry_after": 12
        }
    }
}

Admin API for Managing Limits

// ASP.NET Core middleware for rate limiting
public class RateLimitMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IMultiTierRateLimiter _limiter;
    private readonly ILogger<RateLimitMiddleware> _logger;

    public RateLimitMiddleware(RequestDelegate next,
        IMultiTierRateLimiter limiter,
        ILogger<RateLimitMiddleware> logger)
    {
        _next = next;
        _limiter = limiter;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var apiKey = context.Request.Headers["X-API-Key"].FirstOrDefault()
            ?? context.Request.Query["api_key"].FirstOrDefault();

        if (string.IsNullOrEmpty(apiKey))
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsJsonAsync(new { error = "API key required" });
            return;
        }

        var endpoint = $"{context.Request.Method}:{context.Request.Path}";

        var result = await _limiter.CheckAsync(apiKey, endpoint);

        // 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"] =
            DateTimeOffset.UtcNow.Add(result.RetryAfter).ToUnixTimeSeconds().ToString();

        if (!result.Allowed)
        {
            context.Response.StatusCode = 429;
            context.Response.Headers["Retry-After"] =
                ((int)result.RetryAfter.TotalSeconds).ToString();

            _logger.LogWarning("Rate limit exceeded for {ApiKey} on {Endpoint}",
                apiKey, endpoint);

            await context.Response.WriteAsJsonAsync(new
            {
                error = new
                {
                    code = "RATE_LIMIT_EXCEEDED",
                    message = $"Rate limit exceeded. Retry after {result.RetryAfter.TotalSeconds} seconds.",
                    retry_after = (int)result.RetryAfter.TotalSeconds
                }
            });
            return;
        }

        await _next(context);
    }
}

// Registration in Program.cs
public static class RateLimitServiceExtensions
{
    public static IServiceCollection AddRateLimiting(this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddSingleton<IConnectionMultiplexer>(sp =>
            ConnectionMultiplexer.Connect(configuration["Redis:ConnectionString"]));

        services.AddScoped<RedisSlidingWindowRateLimiter>();
        services.AddScoped<DistributedTokenBucket>();
        services.AddScoped<IMultiTierRateLimiter, MultiTierRateLimiter>();

        return services;
    }
}

13. Database Design

Rate Limit State Storage

StoragePurposeConsistencyLatency
Redis (primary)Real-time counters, token bucketsEventual (async replication)< 1ms
PostgreSQL (config)Client tier configs, rate limit rulesStrong5-10ms
Local in-memory cacheCached configs, hot key countersEventual< 0.01ms

PostgreSQL Schema for Config

CREATE TABLE rate_limit_clients (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    api_key VARCHAR(64) UNIQUE NOT NULL,
    client_name VARCHAR(255) NOT NULL,
    tier VARCHAR(32) NOT NULL DEFAULT 'free',
    global_per_second INT NOT NULL DEFAULT 10,
    global_per_minute INT NOT NULL DEFAULT 100,
    global_per_hour INT NOT NULL DEFAULT 10000,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE rate_limit_endpoint_overrides (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    client_id UUID REFERENCES rate_limit_clients(id),
    endpoint_pattern VARCHAR(255) NOT NULL,
    per_second INT,
    per_minute INT,
    is_bypassed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_clients_api_key ON rate_limit_clients(api_key);
CREATE INDEX idx_overrides_client ON rate_limit_endpoint_overrides(client_id);

Config Cache with Invalidation

public class CachedConfigStore : IConfigurationStore
{
    private readonly PostgreSQLConfigStore _db;
    private readonly IDistributedCache _cache;
    private readonly IMemoryCache _localCache;

    private static readonly TimeSpan LocalCacheTtl = TimeSpan.FromSeconds(30);
    private static readonly TimeSpan DistributedCacheTtl = TimeSpan.FromMinutes(5);

    public async Task<ClientTierConfig> GetClientConfig(string apiKey)
    {
        // L1: Local in-memory cache (fastest)
        if (_localCache.TryGetValue<ClientTierConfig>($"config:{apiKey}", out var local))
            return local;

        // L2: Redis distributed cache
        var cached = await _cache.GetStringAsync($"config:{apiKey}");
        if (cached != null)
        {
            var config = JsonSerializer.Deserialize<ClientTierConfig>(cached);
            _localCache.Set($"config:{apiKey}", config, LocalCacheTtl);
            return config;
        }

        // L3: PostgreSQL (source of truth)
        var dbConfig = await _db.GetByApiKey(apiKey);
        if (dbConfig != null)
        {
            var json = JsonSerializer.Serialize(dbConfig);
            await _cache.SetStringAsync($"config:{apiKey}", json,
                new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = DistributedCacheTtl });
            _localCache.Set($"config:{apiKey}", dbConfig, LocalCacheTtl);
        }

        return dbConfig;
    }

    public async Task UpdateClientConfig(string apiKey, ClientTierConfig config)
    {
        await _db.Update(config);
        await _cache.RemoveAsync($"config:{apiKey}");
        _localCache.Remove($"config:{apiKey}");
    }
}

14. Read/Write Path (Data Flow)

Rate Limit Check Path

Complete Rate Limit Check Flow

graph TD A[API Request] --> B[Extract API Key + Endpoint] B --> C[Load Client Config] C --> D{Config in local cache?} D -->|Yes| E[Use cached config] D -->|No| F[Load from Redis cache] F --> G{In Redis cache?} G -->|Yes| E G -->|No| H[Load from PostgreSQL] H --> E E --> I[Resolve rate limit rules] I --> J{Is endpoint bypassed?} J -->|Yes| K[Allow - skip check] J -->|No| L[Execute Lua script in Redis] L --> M{Within limit?} M -->|Yes| N[Set response headers
Forward to backend] M -->|No| O[Set 429 response
Add Retry-After header] N --> P[Backend processes request] P --> Q[Return response to client] O --> R[Return 429 to client] style L fill:#ff6b35 style N fill:#4caf50 style O fill:#ff4444

Async Rate Limit Update Path

Background Rate Limit Sync

sequenceDiagram participant GW as API Gateway participant RL as Rate Limiter participant Redis as Redis participant Config as Config Store Note over GW: Request completes GW->>RL: Log request for analytics RL->>Redis: INCR analytics counter RL->>RL: Batch write to analytics DB Note over RL: Every 5 minutes RL->>Config: Sync config changes Config-->>RL: Updated limits RL->>Redis: Update cached configs

15. Distributed Rate Limiting

The fundamental challenge: multiple API gateway nodes must agree on a single global counter. There are several approaches, each with different trade-offs.

Approach 1: Centralized Counter (Redis)

All gateway nodes query a central Redis cluster. This is the most common approach in production.

public class CentralizedRateLimiter
{
    private readonly IConnectionMultiplexer _redis;
    private readonly int _retryCount = 3;

    public async Task<RateLimitResult> CheckAsync(string key, int limit, TimeSpan window)
    {
        for (int i = 0; i < _retryCount; i++)
        {
            try
            {
                var db = _redis.GetDatabase();
                var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
                var windowMs = (long)window.TotalMilliseconds;

                var script = @"
                    local key = KEYS[1]
                    local now = tonumber(ARGV[1])
                    local window = tonumber(ARGV[2])
                    local limit = tonumber(ARGV[3])
                    local window_start = now - window

                    redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
                    local current = redis.call('ZCARD', key)

                    if current < limit then
                        redis.call('ZADD', key, now, now .. '-' .. ARGV[4])
                        redis.call('PEXPIRE', key, window)
                        return {1, limit - current - 1, 0}
                    else
                        local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
                        local retry = 0
                        if #oldest > 0 then
                            retry = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
                        end
                        return {0, 0, retry}
                    end
                ";

                var result = await db.ScriptEvaluateAsync(script,
                    new RedisKey[] { $"rl:{key}" },
                    new RedisValue[] { now, windowMs, limit, Guid.NewGuid().ToString("N") });

                var values = (long[])result;
                return new RateLimitResult
                {
                    Allowed = values[0] == 1,
                    Remaining = (int)values[1],
                    Limit = limit,
                    RetryAfter = TimeSpan.FromSeconds((int)values[2])
                };
            }
            catch (RedisException ex)
            {
                _logger.LogWarning("Redis error on attempt {Attempt}: {Error}", i + 1, ex.Message);
                if (i == _retryCount - 1)
                {
                    // Fail open on Redis failure
                    return RateLimitResult.Allow(limit);
                }
                await Task.Delay(TimeSpan.FromMilliseconds(10 * (i + 1)));
            }
        }

        return RateLimitResult.Allow(limit);
    }
}

Approach 2: Local Counter with Periodic Sync

Each gateway node maintains a local counter and periodically syncs with other nodes. Better latency but less accurate.

public class LocalSyncRateLimiter
{
    private readonly ConcurrentDictionary<string, LocalCounter> _localCounters = new();
    private readonly IMultiNodeSync _sync;
    private readonly int _nodeCount;

    public LocalSyncRateLimiter(IMultiNodeSync sync, int nodeCount)
    {
        _sync = sync;
        _nodeCount = nodeCount;
    }

    public RateLimitResult Allow(string key, int globalLimit)
    {
        // Each node gets 1/N of the global limit
        var localLimit = globalLimit / _nodeCount;

        var counter = _localCounters.GetOrAdd(key, _ => new LocalCounter
        {
            WindowStart = DateTimeOffset.UtcNow,
            Count = 0
        });

        lock (counter)
        {
            if (DateTimeOffset.UtcNow - counter.WindowStart > TimeSpan.FromSeconds(60))
            {
                counter.WindowStart = DateTimeOffset.UtcNow;
                counter.Count = 0;
            }

            counter.Count++;

            if (counter.Count > localLimit)
            {
                return new RateLimitResult
                {
                    Allowed = false,
                    Remaining = 0,
                    Limit = globalLimit,
                    RetryAfter = counter.WindowStart.AddSeconds(60) - DateTimeOffset.UtcNow
                };
            }

            return new RateLimitResult
            {
                Allowed = true,
                Remaining = globalLimit - (counter.Count * _nodeCount),
                Limit = globalLimit
            };
        }
    }
}

public class LocalCounter
{
    public DateTimeOffset WindowStart { get; set; }
    public long Count { get; set; }
}

Approach 3: Consensus-Based (Raft)

Use a consensus algorithm to replicate counters. Provides strong consistency but adds latency. Suitable for financial use cases.

Distributed Rate Limiting Approaches Comparison

graph TB subgraph "Centralized (Redis)" A1[GW Node 1] --> R[Redis Cluster] A2[GW Node 2] --> R A3[GW Node 3] --> R R --> ACK1[Atomic Lua Script] end subgraph "Local + Sync" B1[GW Node 1
Local Counter] <-->|"Periodic sync"| B2[GW Node 2
Local Counter] B2 <-->|"Periodic sync"| B3[GW Node 3
Local Counter] end subgraph "Consensus (Raft)" C1[Raft Leader] --> C2[Raft Follower 1] C1 --> C3[Raft Follower 2] C1 --> C4[Raft Follower 3] end style R fill:#ff6b35 style ACK1 fill:#0099ff

Approach Comparison

ApproachAccuracyLatencyComplexityFailure Mode
Centralized (Redis)High (~99%)1-3msLowFail open on Redis down
Local + SyncMedium (~95%)< 0.01msMediumOver-counting during sync delay
Consensus (Raft)Exact5-15msHighUnavailable if minority of nodes down
Interview Tip: Start with centralized Redis (most practical), then discuss local+sync as a fallback for when Redis is unavailable. Mention consensus only if the interviewer asks about strong consistency requirements.

16. Caching Strategy

Multi-Level Caching for Rate Limits

Caching Hierarchy for Rate Limiting

graph TD A[Rate Limit Request] --> B{L1: Local Memory
Per-GW-node, <0.01ms} B -->|Hit| C[Return cached count] B -->|Miss| D{L2: Redis
Shared, 1-3ms} D -->|Hit| E[Update L1 cache
Return result] D -->|Miss| F[Execute Lua script] F --> G[Update L2 + L1] G --> H[Return result] style B fill:#4caf50 style D fill:#ff6b35
public class CachedRateLimiter
{
    private readonly IMultiTierRateLimiter _inner;
    private readonly IMemoryCache _localCache;
    private readonly TimeSpan _localCacheTtl = TimeSpan.FromMilliseconds(100);

    public CachedRateLimiter(IMultiTierRateLimiter inner)
    {
        _inner = inner;
        _localCache = new MemoryCache(new MemoryCacheOptions
        {
            SizeLimit = 10000,
            CompactionPercentage = 0.25
        });
    }

    public async Task<RateLimitResult> CheckAsync(string apiKey, string endpoint)
    {
        var cacheKey = $"{apiKey}:{endpoint}";

        // Check local cache for very recent results
        if (_localCache.TryGetValue<RateLimitResult>(cacheKey, out var cached)
            && cached != null)
        {
            // Only use cache if result is very recent (within 100ms)
            if (DateTimeOffset.UtcNow - cached.CheckedAt < _localCacheTtl)
            {
                return cached;
            }
        }

        var result = await _inner.CheckAsync(apiKey, endpoint);
        result.CheckedAt = DateTimeOffset.UtcNow;

        _localCache.Set(cacheKey, result, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = _localCacheTtl,
            Size = 1
        });

        return result;
    }
}

Cache Invalidation for Config Changes

public class ConfigChangeWatcher : BackgroundService
{
    private readonly IConnectionMultiplexer _redis;
    private readonly IConfigurationStore _configStore;
    private readonly ILogger<ConfigChangeWatcher> _logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var subscriber = _redis.GetSubscriber();
        await subscriber.SubscribeAsync("rate_limit:config_changes", async (channel, message) =>
        {
            var change = JsonSerializer.Deserialize<ConfigChange>(message);

            _logger.LogInformation("Rate limit config changed for {ApiKey}", change.ApiKey);

            // Invalidate local caches
            _configStore.InvalidateCache(change.ApiKey);
        });

        await Task.Delay(Timeout.Infinite, stoppingToken);
    }
}

public class ConfigChange
{
    public string ApiKey { get; set; }
    public string ChangeType { get; set; }  // "update", "delete", "tier_change"
    public DateTime Timestamp { get; set; }
}

17. Scalability

Scaling the Rate Limiter

DimensionCurrentScale TargetStrategy
Requests/sec50K500KAdd Redis nodes, use local caching
Active clients100K10MPartition Redis by key hash
API gateways350Local + sync approach for high scale
Endpoints50500Rule engine with pattern matching

Redis Cluster Sharding

public class ShardedRateLimiter
{
    private readonly Dictionary<int, IConnectionMultiplexer> _shards = new();

    public ShardedRateLimiter(IConfiguration config)
    {
        var shardConnections = config.GetSection("Redis:Shards").Get<string[]>();
        for (int i = 0; i < shardConnections.Length; i++)
        {
            _shards[i] = ConnectionMultiplexer.Connect(shardConnections[i]);
        }
    }

    private IConnectionMultiplexer GetShard(string key)
    {
        var hash = Math.Abs(key.GetHashCode());
        var shardIndex = hash % _shards.Count;
        return _shards[shardIndex];
    }

    public async Task<RateLimitResult> CheckAsync(string apiKey, int limit, TimeSpan window)
    {
        var shard = GetShard(apiKey);
        var db = shard.GetDatabase();

        // Execute Lua script on the appropriate shard
        var result = await db.ScriptEvaluateAsync(LuaScript,
            new RedisKey[] { $"rl:{apiKey}" },
            new RedisValue[] {
                DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
                (long)window.TotalMilliseconds,
                limit
            });

        return ParseResult(result, limit);
    }
}

18. Distributed Systems Design

Failure Scenarios

Distributed Failure Handling

graph TD A[Redis Cluster Down] --> B[Fail Open
Allow all requests] A --> C[Use local counters
as fallback] A --> D[Alert on-call] B --> E[Monitor traffic spike] C --> F[Periodic sync when Redis recovers] F --> G[Reconcile local vs global counters] H[Network Partition] --> I[Partition with majority
continues normally] H --> J[Minority partition
uses local counters] I --> K[Re-sync after partition heals] J --> K
public class ResilientRateLimiter : IMultiTierRateLimiter
{
    private readonly RedisSlidingWindowRateLimiter _primary;
    private readonly LocalSyncRateLimiter _fallback;
    private readonly CircuitBreaker _circuitBreaker;
    private readonly ILogger<ResilientRateLimiter> _logger;

    public ResilientRateLimiter(
        RedisSlidingWindowRateLimiter primary,
        LocalSyncRateLimiter fallback,
        ILogger<ResilientRateLimiter> logger)
    {
        _primary = primary;
        _fallback = fallback;
        _logger = logger;

        _circuitBreaker = new CircuitBreaker(
            failureThreshold: 5,
            recoveryTimeout: TimeSpan.FromSeconds(30),
            onStateChange: (state) =>
            {
                _logger.LogWarning("Rate limiter circuit breaker state changed to {State}", state);
            });
    }

    public async Task<RateLimitResult> CheckAsync(string apiKey, string endpoint)
    {
        try
        {
            return await _circuitBreaker.ExecuteAsync(() =>
                _primary.CheckAsync(apiKey, endpoint));
        }
        catch (CircuitOpenException)
        {
            _logger.LogWarning("Circuit open, using local fallback for {ApiKey}", apiKey);

            // Fall back to local rate limiting
            var config = await GetConfigFromCache(apiKey);
            return _fallback.Allow($"{apiKey}:{endpoint}", config.PerMinuteLimit);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Rate limit check failed, failing open for {ApiKey}", apiKey);

            // Fail open - allow the request
            return RateLimitResult.Allow(int.MaxValue);
        }
    }
}

19. Consistency Models

Consistency in Rate Limiting

ScenarioConsistency NeededTrade-off
Billing protectionStrong (exact counts)Higher latency, use consensus
DDoS protectionEventual (approximate OK)Lower latency, fail open
API quota enforcementApproximate (within 5%)Balance latency vs accuracy
SLA monitoringEventual (aggregated)Async analytics pipeline

Key Insight: Rate Limiting is Inherently Approximate

Unlike financial transactions, rate limiting does not require exact consistency. Being off by 1-2 requests is acceptable. This means we can optimize for availability and latency over strong consistency. The sliding window counter with Redis gives ~99% accuracy with sub-3ms latency — the right trade-off for most APIs.

20. Reliability

Reliability Targets

ComponentTargetStrategy
Rate limit check99.99% availabilityRedis cluster + local fallback
Config store99.9% availabilityMulti-layer cache (L1/L2/L3)
AccuracyWithin 1% of configured limitSliding window counter algorithm
Latencyp99 < 5msLocal caching, connection pooling

Fail-Open Design

public class FailOpenRateLimiter
{
    private readonly RedisSlidingWindowRateLimiter _redisLimiter;
    private readonly ILogger<FailOpenRateLimiter> _logger;

    public async Task<RateLimitResult> CheckAsync(string key, int limit, TimeSpan window)
    {
        try
        {
            using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
            return await Task.Run(
                () => _redisLimiter.CheckAsync(key, limit, window),
                timeout.Token);
        }
        catch (OperationCanceledException)
        {
            _logger.LogWarning("Rate limit check timed out for {Key}, failing open", key);
            return RateLimitResult.Allow(limit);
        }
        catch (RedisConnectionException ex)
        {
            _logger.LogWarning("Redis connection failed for {Key}, failing open: {Error}",
                key, ex.Message);
            return RateLimitResult.Allow(limit);
        }
    }
}

21. Security

Security Considerations

  • API key validation: Never trust client-provided keys without validation. Use HMAC-signed keys.
  • IP spoofing prevention: Rate limit by API key, not IP alone (IPs can be spoofed or shared).
  • Key rotation: Support seamless API key rotation without losing rate limit state.
  • Encryption: API keys in transit (TLS) and at rest (encrypted in Redis/DB).
  • Audit logging: Log all rate limit violations for security analysis.
public class SecureApiKeyValidator
{
    private readonly byte[] _hmacKey;

    public bool ValidateKey(string apiKey)
    {
        if (string.IsNullOrEmpty(apiKey) || apiKey.Length < 32)
            return false;

        // Check format: prefix HMAC-SHA256 signature
        var parts = apiKey.Split('.');
        if (parts.Length != 2)
            return false;

        var prefix = parts[0];
        var signature = parts[1];

        using var hmac = new HMACSHA256(_hmacKey);
        var expectedSignature = Convert.ToBase64String(
            hmac.ComputeHash(Encoding.UTF8.GetBytes(prefix)));

        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(signature),
            Encoding.UTF8.GetBytes(expectedSignature));
    }
}

22. Observability

Key Metrics

MetricTypeDescriptionAlert Threshold
ratelimit_checks_totalCounterTotal rate limit checks-
ratelimit_rejections_totalCounterTotal rejections (429s)Rejection rate > 10%
ratelimit_check_duration_msHistogramTime to execute rate limit checkp99 > 10ms
ratelimit_redis_errors_totalCounterRedis connection errorsAny error
ratelimit_fallback_activationsCounterTimes local fallback was usedAny activation
ratelimit_circuit_open_totalGauge1 if circuit breaker is openAny value > 0
ratelimit_config_cache_hitsCounterConfig cache hit rateHit rate < 90%

Prometheus Configuration

scrape_configs:
  - job_name: 'rate-limiter'
    static_configs:
      - targets: ['rate-limiter:8080']
    metrics_path: /metrics
    scrape_interval: 15s

alerting:
  rules:
    - alert: HighRateLimitRejectionRate
      expr: rate(ratelimit_rejections_total[5m]) / rate(ratelimit_checks_total[5m]) > 0.1
      for: 5m
      labels:
        severity: warning
      annotations:
        summary: "High rate limit rejection rate"
        description: "Rejection rate is {{ $value | humanizePercentage }}"

    - alert: RateLimiterCircuitOpen
      expr: ratelimit_circuit_open_total > 0
      for: 1m
      labels:
        severity: critical
      annotations:
        summary: "Rate limiter circuit breaker is open"
        description: "Rate limiter is using local fallback. Redis may be down."

Distributed Tracing

public class TracedRateLimiter : IMultiTierRateLimiter
{
    private readonly IMultiTierRateLimiter _inner;
    private readonly Tracer _tracer;

    public async Task<RateLimitResult> CheckAsync(string apiKey, string endpoint)
    {
        using var span = _tracer.StartActiveSpan("rate_limit_check");
        span.SetAttribute("api_key", apiKey);
        span.SetAttribute("endpoint", endpoint);

        var sw = Stopwatch.StartNew();
        var result = await _inner.CheckAsync(apiKey, endpoint);
        sw.Stop();

        span.SetAttribute("ratelimit.allowed", result.Allowed);
        span.SetAttribute("ratelimit.remaining", result.Remaining);
        span.SetAttribute("ratelimit.latency_ms", sw.ElapsedMilliseconds);

        if (!result.Allowed)
            span.SetStatus(Status.Error.WithDescription("Rate limit exceeded"));

        return result;
    }
}

23. High Availability

Multi-Region Rate Limiting

Global Rate Limiting Architecture

graph TB subgraph "US Region" US_GW[US API Gateway] --> US_Redis[(US Redis)] end subgraph "EU Region" EU_GW[EU API Gateway] --> EU_Redis[(EU Redis)] end subgraph "Cross-Region Sync" US_Redis <-->|"Async replication
~50ms lag"| EU_Redis end subgraph "Global Config" ConfigDB[(Global Config DB)] --> US_Redis ConfigDB --> EU_Redis end US_Client[US Client] --> US_GW EU_Client[EU Client] --> EU_GW

Multi-Region Strategy

StrategyAccuracyLatencyComplexity
Global Redis (single region)100%50-200ms cross-regionLow
Regional Redis + async sync~95%1-3ms localMedium
Regional with local fallback~90%< 1msHigh
CRDT-based counters~98%< 1msVery High

24. Performance

Latency Breakdown

ComponentP50P99Optimization
API key extraction0.01ms0.05msString interning
Config lookup (cached)0.05ms0.2msMemoryCache L1
Redis Lua script0.5ms2msConnection pooling, pipelining
Response header set0.01ms0.05msBulk header writing
Total0.6ms2.3ms

Optimization Techniques

public class OptimizedRateLimiter
{
    // 1. Connection pooling - reuse Redis connections
    private readonly Lazy<IConnectionMultiplexer> _redis = new(() =>
        ConnectionMultiplexer.Connect(new ConfigurationOptions
        {
            EndPoints = { "redis:6379" },
            AbortOnConnectFail = false,
            ConnectTimeout = 100,
            SyncTimeout = 50,
            KeepAlive = 60,
            MaxAzureCacheClientsPerEndpoint = 20
        }));

    // 2. Pipeline multiple operations
    public async Task<List<RateLimitResult>> BatchCheckAsync(List<string> keys, int limit)
    {
        var db = _redis.Value.GetDatabase();
        var tasks = new List<Task>();

        // Pipeline all checks into a single round-trip
        var batch = db.CreateBatch();
        foreach (var key in keys)
        {
            tasks.Add(batch.ScriptEvaluateAsync(
                LuaScript,
                new RedisKey[] { $"rl:{key}" },
                new RedisValue[] { DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), 60000, limit }));
        }
        batch.Execute();
        await Task.WhenAll(tasks);

        return tasks.Select(t => ParseResult(((Task<RedisResult>)t).Result, limit)).ToList();
    }

    // 3. Async processing for non-critical path
    public void LogRateLimitEvent(string apiKey, bool allowed)
    {
        // Fire-and-forget analytics logging
        _ = Task.Run(async () =>
        {
            await _analyticsStore.LogAsync(new RateLimitEvent
            {
                ApiKey = apiKey,
                Allowed = allowed,
                Timestamp = DateTimeOffset.UtcNow
            });
        });
    }
}

25. Cost Analysis

Infrastructure Cost

ComponentSpecMonthly Cost
Redis Cluster (3 nodes)AWS ElastiCache r6g.large$450
PostgreSQL (config store)AWS RDS db.t3.micro$50
API Gateway nodes (existing)Included in gateway cost$0
MonitoringCloudWatch metrics$50
Total$550/month
Cost-Effective Design: Rate limiting adds minimal cost (~$550/month) compared to the backend infrastructure it protects. The ROI is enormous: preventing a single DDoS attack or runaway client from taking down the system saves far more than the rate limiter costs.

26. Failure Scenarios

FailureImpactDetectionRecovery
Redis downCannot check rate limitsConnection error metricsFail open, use local counters
Redis slow (> 100ms)Added latency to API pathp99 latency alertCircuit breaker, local fallback
Config store downCannot load new configsCache miss rate increaseUse cached configs, serve defaults
Counter driftOver/under countingReconciliation jobPeriodic reconciliation with source of truth
Hot key (100K+ req/s)Redis single-shard overloadRedis slowlogLocal caching, key salting

Hot Key Mitigation

public class HotKeyRateLimiter
{
    private readonly IMemoryCache _localCounters;
    private readonly RedisSlidingWindowRateLimiter _redisLimiter;

    public async Task<RateLimitResult> AllowAsync(string key, int limit, TimeSpan window)
    {
        // Check local counter first for potential hot keys
        var localKey = $"local:{key}";
        if (_localCounters.TryGetValue<int>(localKey, out var localCount))
        {
            // If local count is high, use local counting to avoid Redis overload
            if (localCount > 100)  // Threshold for "hot key"
            {
                return CheckLocal(key, limit, window);
            }
        }

        _localCounters.Set(localKey,
            _localCounters.Get<int>(localKey) + 1,
            TimeSpan.FromSeconds(5));

        return await _redisLimiter.CheckAsync(key, limit, window);
    }

    private RateLimitResult CheckLocal(string key, int limit, TimeSpan window)
    {
        // Local sliding window for hot keys
        // Trades accuracy for Redis protection
        var counter = _localCounters.GetOrCreate(key, _ =>
        {
            return new SlidingWindowCounter(limit, window);
        });

        return counter.Allow();
    }
}

27. Technology Choices (C# & Alternatives)

C# Implementation Stack

ComponentC# TechnologyReasoning
Web FrameworkASP.NET Core 8High performance, built-in middleware pipeline
Redis ClientStackExchange.RedisMature, connection pooling, Lua script support
CachingMicrosoft.Extensions.Caching.MemoryBuilt-in, L1 cache for rate limit state
ConfigurationPostgreSQL + EF CoreACID for config, LINQ queries
MonitoringPrometheus.NET + OpenTelemetryIndustry standard metrics and tracing
ResiliencePollyCircuit breaker, retry, timeout policies
SerializationSystem.Text.JsonFast, built-in, minimal allocation

NuGet Packages

<PackageReference Include="StackExchange.Redis" Version="2.7.*" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.*" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.*" />
<PackageReference Include="Polly" Version="8.3.*" />
<PackageReference Include="prometheus-net" Version="8.2.*" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.7.*" />

Alternative Implementations

LanguageLibraryNotes
Gogo-redis + custom middlewareExcellent concurrency, lower memory
JavaBucket4j + RedissonMature rate limiting library
Node.jsrate-limiter-flexibleMultiple algorithms, Redis support
Pythonlimits + aioredisGood for FastAPI/Flask
Rustgovernor + fredMaximum performance, zero-cost abstractions

28. Alternatives & Trade-offs

Build vs Buy

OptionProsConsWhen to Use
Custom (this design)Full control, tailored to needsDevelopment cost, maintenanceUnique requirements, high volume
AWS API Gateway throttlingManaged, scales automaticallyLimited customization, vendor lock-inAWS-native, simple needs
Kong / Envoy rate limitingBattle-tested, plugin ecosystemOperational overheadAlready using Kong/Envoy
Cloudflare Rate LimitingEdge-based, DDoS protectionCDN-dependent, cost at scalePublic-facing APIs behind CDN
Upstash Rate LimitingServerless Redis, pay-per-useNewer, less controlServerless architectures

29. Real-World Case Studies

Stripe API Rate Limiting

  • Strategy: Per-key limits with burst allowance. 100 requests/second for most endpoints.
  • Headers: Returns RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After.
  • Special handling: Write operations (charges, refunds) have stricter limits than reads.
  • Lesson: Tiered limits based on endpoint cost, not just volume.

Twitter API Rate Limiting

  • Strategy: Application-level and user-level limits. 15-minute sliding windows.
  • Complexity: Different endpoints have wildly different limits (search: 450/15min, tweet: 300/15min).
  • Lesson: Per-endpoint limits are essential for APIs with varying backend costs.

GitHub API Rate Limiting

  • Strategy: 5000 requests/hour for authenticated users. Uses sliding window.
  • Special features: Secondary rate limits for specific actions (e.g., creating issues).
  • Lesson: Support both global and action-specific rate limits.

30. Interview Follow-ups

Common Deep-Dive Questions

QuestionKey Points
How do you handle rate limiting across multiple data centers?Regional Redis with async sync, or CRDT-based counters for eventual consistency
What if Redis goes down?Fail open with local counters, circuit breaker pattern, alert on-call
How do you handle rate limiting for WebSocket connections?Count messages not connections, use token bucket for burst tolerance
How do you rate limit by IP and API key simultaneously?Multi-key rate limiting: check both keys, reject if either limit exceeded
Can you implement rate limiting without Redis?Yes: local counters + gossip sync, or consensus (Raft), or database-backed
How do you handle rate limiting for batch endpoints?Count batch size against limit, or use separate limits for batch operations
How do you test rate limiting in production?Shadow testing, canary deployments, chaos engineering (Redis kill)

31. Senior/Staff/Principal Engineering Discussion

Architectural Decisions

  • Rate limiting at gateway vs service level: Gateway-level is simpler but less precise. Service-level allows per-endpoint customization. Recommendation: gateway for global limits, service for business-specific limits.
  • Centralized vs distributed state: Centralized (Redis) is simpler but a single point of failure. Distributed (local + sync) is more complex but more resilient. Recommendation: start centralized, add distributed fallback.
  • Accuracy requirements: For billing, you need exact counts (use database). For DDoS protection, approximate is fine (use Redis). For most APIs, sliding window counter (99% accurate) is sufficient.

Operational Considerations

  • Capacity planning: Redis memory usage = 200 bytes × active_keys × window_count. For 1M keys with 3 windows: ~600MB.
  • Monitoring dashboards: Rate limit hit rate, rejection rate by client tier, Redis latency, fallback activation count.
  • Runbooks: Redis down procedure, hot key mitigation, config change rollout, circuit breaker tuning.
  • Chaos engineering: Regularly kill Redis to test fail-open behavior. Simulate hot keys to test fallback.

32. Architecture Evolution

Rate Limiter Evolution

graph TB subgraph "Phase 1: Simple" P1[In-memory counter
Single node] --> P2[Fixed window
Per-second limits] end subgraph "Phase 2: Distributed" P3[Redis-backed
Sliding window] --> P4[Multi-tier limits
Per-endpoint rules] end subgraph "Phase 3: Production" P5[Circuit breaker + fallback] --> P6[Multi-region
Async sync] end subgraph "Phase 4: Intelligent" P7[ML-based dynamic limits] --> P8[Anomaly detection
Auto-scaling limits] end P1 --> P3 --> P5 --> P7

33. Key Takeaways

Core Principles

  1. Sliding window counter is the recommended default algorithm: ~99% accuracy, O(1) memory, no boundary problem.
  2. Redis + Lua scripts provide atomic, distributed rate limiting with sub-3ms latency.
  3. Fail open is the correct default when the rate limiter fails — a brief unlimited period is better than a complete outage.
  4. Multi-tier limits (per-second, per-minute, per-hour) protect against different attack patterns.
  5. Rate limit headers (X-RateLimit-*) are essential for client integration and debugging.
  6. Caching configs at multiple levels (L1 memory, L2 Redis, L3 database) minimizes latency impact.
  7. Circuit breaker pattern prevents cascading failures when Redis is slow or down.
  8. Observability (metrics, traces, logs) is critical for operational awareness of rate limiting behavior.
  9. Per-endpoint limits allow different rates for cheap vs expensive operations.
  10. Hot key mitigation (local caching, key salting) prevents Redis overload from popular API keys.

Interview Quick Reference

TopicKey Points
AlgorithmSliding window counter (recommended), token bucket (for bursts), leaky bucket (for smoothing)
DistributedRedis Lua scripts (atomic), local + sync (fallback), consensus (strong consistency)
FailureFail open, circuit breaker, local fallback counters
HeadersX-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After
Multi-tenancyTiered limits (Free/Pro/Enterprise), per-endpoint overrides
Performance< 1ms overhead, connection pooling, local caching, batch checks

34. References & Further Reading

  • Designing Data-Intensive Applications (Kleppmann, 2017) — Chapter on distributed systems
  • Stripe API Rate Limiting Documentation — Real-world production rate limiting
  • GitHub REST API Rate Limiting — Sliding window implementation
  • Cloudflare Rate Limiting — Edge-based rate limiting at scale
  • Redis Documentation: Sorted Sets — Implementation basis for sliding window log
  • Token Bucket Algorithm (RFC 2697) — Original specification
  • Leaky Bucket Algorithm (RFC 2698) — Traffic shaping specification
  • Microsoft: Rate Limit Pattern — Azure architecture center guidance
  • Polly Project — .NET resilience and circuit breaker library
  • StackExchange.Redis — High-performance Redis client for .NET

35. Conclusion

Rate limiting is a deceptively simple concept with deep distributed systems implications. The core question — "how many requests has this client made in the last minute?" — requires careful consideration of algorithms (fixed window vs sliding window vs token bucket), coordination (centralized Redis vs local counters vs consensus), and failure modes (fail open vs fail closed).

The recommended approach for most production systems is a sliding window counter backed by Redis with Lua scripts. This gives ~99% accuracy, sub-3ms latency, and natural support for distributed coordination. Combined with a circuit breaker pattern, local fallback counters, and comprehensive observability, this design provides robust rate limiting that protects your backend services without becoming a bottleneck.

In your interview, start with the simplest solution (fixed window counter), then evolve to sliding window when the interviewer asks about the boundary problem. Discuss distributed coordination with Redis, then address failure modes with the circuit breaker pattern. This progressive deepening demonstrates both algorithmic knowledge and production awareness.

Quick Recap for Interviews

  1. Algorithms: Fixed window (simple), sliding window log (exact), sliding window counter (recommended), token bucket (bursts), leaky bucket (smooth)
  2. Distributed: Redis Lua scripts for atomic operations, local fallback for resilience
  3. Failure: Fail open, circuit breaker, local counters as fallback
  4. Headers: X-RateLimit-Limit/Remaining/Reset + Retry-After for 429s
  5. Multi-tenancy: Tiered limits, per-endpoint overrides, dynamic config
  6. Performance: < 1ms overhead through local caching and connection pooling

36. Rate Limiter Observability and Alerting

Rate limiting is invisible until it breaks. Comprehensive observability is essential for detecting misconfigurations, understanding traffic patterns, and identifying abuse. The monitoring system must track rejection rates, accuracy metrics, and per-client rate limit consumption in real-time.

public class RateLimiterMetrics
{
    private readonly IMetricsCollector _metrics;

    public void RecordRateLimitCheck(
        string clientId, string endpoint, bool allowed,
        long currentCount, long limit, double windowProgress)
    {
        var tags = new Dictionary<string, string>
        {
            ["client"] = clientId,
            ["endpoint"] = endpoint,
            ["result"] = allowed ? "allowed" : "rejected"
        };

        _metrics.IncrementCounter("rate_limit.checks", tags);
        _metrics.SetGauge("rate_limit.current_count", currentCount, tags);
        _metrics.SetGauge("rate_limit.utilization",
            (double)currentCount / limit, tags);

        if (!allowed)
        {
            _metrics.IncrementCounter("rate_limit.rejections", tags);
        }

        _metrics.RecordHistogram("rate_limit.check_latency_ms",
            GetCheckLatency(), tags);
    }

    public void RecordLimiterHealth(
        string node, bool isHealthy, double accuracy)
    {
        _metrics.SetGauge("rate_limit.health", isHealthy ? 1 : 0,
            new Dictionary<string, string> { ["node"] = node });
        _metrics.SetGauge("rate_limit.accuracy", accuracy,
            new Dictionary<string, string> { ["node"] = node });
    }
}

Key Rate Limiting Metrics

MetricDescriptionAlert Threshold
Rejection Rate% of requests rejected> 10% (investigate clients)
Check Latency P9999th percentile rate limit check time> 5ms
AccuracyActual vs expected counter match rate< 99%
Redis AvailabilityRedis connection success rate< 99.9%
Fallback ActivationLocal counter fallback events> 0 per minute
Top Clients by UsageRate consumption by client tierPer-tier analysis

© 2025 Ayodhyya - The System Design Codex. All rights reserved.

This is part of "The Complete System Design Interview Handbook" series.