Chapter 28: Rate Limiting & Throttling

Why Rate Limit

Rate limiting controls the frequency of requests to prevent system overload and ensure fair resource allocation.

Goal Description Example Scenario
Prevent Abuse Block malicious actors, DDoS attacks, credential stuffing Limit login attempts to 5 per minute per IP
Protect Resources Prevent resource exhaustion (CPU, memory, DB connections) API servers can only handle 10K req/sec
Fair Usage Ensure all users get proportional access Prevent one user from monopolizing API quota
Cost Control Limit usage of expensive operations Cap video transcoding jobs at 100/day per user
SLA Enforcement Tie limits to subscription tiers Free: 100 req/hr, Pro: 10K req/hr, Enterprise: unlimited
Graceful Degradation Shed load before cascading failures Return 429 instead of crashing under load
Interview Tip: Always clarify the goal of rate limiting first. A system preventing DDoS attacks has different requirements than one enforcing API quotas. DDoS protection needs fast rejection (low latency), while quota enforcement needs accurate counting across distributed systems.

Where Rate Limiting Applies

Token Bucket

The most widely used rate limiting algorithm. Tokens are added to a bucket at a fixed rate, and each request consumes a token.

graph TB subgraph "Token Bucket" A[Bucket
Capacity: 100 tokens
Current: 45 tokens] B[Refill Rate: 10 tokens/sec] C{Request Arrives} D[Token Available?] E[Allow Request
Consume 1 token] F[Reject Request
429 Too Many Requests] end B -->|Add tokens periodically| A C --> D D -->|Yes| E D -->|No| F E -->|Update bucket| A

Algorithm Parameters

Parameter Description Example
Bucket Size Maximum tokens the bucket can hold (burst capacity) 100 tokens
Refill Rate Tokens added per time unit 10 tokens/second = 600/minute
Initial Tokens Tokens at bucket creation Usually equal to bucket size
Token Cost Tokens consumed per request Typically 1, but can vary by operation

How Bursts Work

Token bucket allows short bursts up to bucket capacity, then enforces sustained rate equal to refill rate.

Time Tokens Available Request Result Explanation
0s 100 50 requests Allow all (50 tokens left) Burst allowed
0.5s 50 60 requests Allow 50, reject 10 Bucket empty after 50
1s 10 20 requests Allow 10, reject 10 Refilled 10 tokens at 1s mark
2s 10 5 requests Allow all (5 tokens left) Steady state: 10 tokens/sec

Implementation Pseudocode

class TokenBucket:
    def __init__(self, capacity, refill_rate):
        self.capacity = capacity
        self.refill_rate = refill_rate  # tokens per second
        self.tokens = capacity
        self.last_refill = now()

    def allow_request(self, tokens_required=1):
        self.refill()

        if self.tokens >= tokens_required:
            self.tokens -= tokens_required
            return True
        return False

    def refill(self):
        current_time = now()
        elapsed = current_time - self.last_refill
        tokens_to_add = elapsed * self.refill_rate

        self.tokens = min(self.capacity, self.tokens + tokens_to_add)
        self.last_refill = current_time

Advantages & Disadvantages

Advantages Disadvantages
Allows bursts (important for real-world traffic) Requires state per user/key (memory overhead)
Memory efficient (2 numbers: tokens + timestamp) Clock drift in distributed systems causes inconsistency
Simple implementation, low CPU Burst can overload downstream if not sized correctly
Easy to reason about and debug No sub-second precision without high-res timers
Interview Tip: Token bucket is the default choice for most systems. Mention it first unless the problem explicitly requires constant output rate (use leaky bucket) or exact per-second limits (use sliding window log).

Real-World Usage

Leaky Bucket

Requests are added to a fixed-size queue and processed at a constant rate, like water dripping from a bucket with a hole.

graph TB subgraph "Leaky Bucket" A[Incoming Requests
Variable Rate] B[Queue/Bucket
Max Size: 100
Current: 30] C[Process at Constant Rate
10 req/sec] D{Queue Full?} E[Add to Queue] F[Reject Request
429] end A --> D D -->|No| E D -->|Yes| F E --> B B -->|Leak| C

Key Characteristics

Property Token Bucket Leaky Bucket
Output Rate Variable (allows bursts) Constant (smooth output)
Burst Handling Immediate if tokens available Queued for later processing
Memory O(1) per key O(queue_size) per key
Latency Low (immediate accept/reject) Higher (queuing delay)
Use Case API rate limiting, burst-tolerant systems Traffic shaping, constant throughput required

Implementation Pseudocode

class LeakyBucket:
    def __init__(self, capacity, leak_rate):
        self.capacity = capacity  # max queue size
        self.leak_rate = leak_rate  # requests per second
        self.queue = []
        self.last_leak = now()

    def allow_request(self, request):
        self.leak()

        if len(self.queue) < self.capacity:
            self.queue.append(request)
            return True
        return False

    def leak(self):
        current_time = now()
        elapsed = current_time - self.last_leak
        requests_to_process = int(elapsed * self.leak_rate)

        # Process (remove) requests from queue
        for i in range(min(requests_to_process, len(self.queue))):
            process(self.queue.pop(0))

        self.last_leak = current_time

When to Use Leaky Bucket

Scenario Why Leaky Bucket Example
Constant Processing Rate Downstream can't handle bursts Video encoding service with fixed workers
Traffic Shaping Smooth out bursty network traffic ISP bandwidth throttling
Background Jobs Maintain steady resource usage Email sending queue (1000/min constant)
Third-Party APIs Respect strict rate limits Twitter API allows exactly 300 req/15min
Interview Tip: Emphasize the difference: Token bucket controls how many requests are allowed, leaky bucket controls how fast requests are processed. Choose leaky bucket when downstream systems need predictable, constant load.

Fixed Window Counter

Count requests in fixed time windows (e.g., 1-minute intervals). Simple but has edge-case issues.

graph LR subgraph "Fixed Window: 100 req/min" A[12:00:00 - 12:00:59
Count: 0] B[12:01:00 - 12:01:59
Count: 0] C[12:02:00 - 12:02:59
Count: 0] end A -->|Reset at 12:01:00| B B -->|Reset at 12:02:00| C

How It Works

Time Window Request Count Action Result
12:00:30 12:00:00 - 12:00:59 50 Request arrives Allow (count = 51)
12:00:59 12:00:00 - 12:00:59 99 Request arrives Allow (count = 100)
12:00:59.9 12:00:00 - 12:00:59 100 Request arrives Reject (limit reached)
12:01:00 12:01:00 - 12:01:59 0 Window resets Count resets to 0
12:01:01 12:01:00 - 12:01:59 0 Request arrives Allow (count = 1)

The Edge-Case Problem

Limit: 100 req/min. User sends 100 requests at 12:00:59 and 100 at 12:01:00 = 200 requests in 1 second, violating the intent of the limit.

gantt title Fixed Window Edge Case: 200 req in 1 second dateFormat ss axisFormat %S section Window 1 100 requests at :59 :active, 59, 1s section Window 2 100 requests at :00 :crit, 00, 1s

Implementation Pseudocode

class FixedWindowCounter:
    def __init__(self, limit, window_size):
        self.limit = limit
        self.window_size = window_size  # seconds
        self.counter = 0
        self.window_start = current_window_start()

    def allow_request(self):
        current_window = current_window_start()

        # Reset counter if new window
        if current_window > self.window_start:
            self.counter = 0
            self.window_start = current_window

        if self.counter < self.limit:
            self.counter += 1
            return True
        return False

    def current_window_start(self):
        return (now() // self.window_size) * self.window_size

Advantages & Disadvantages

Advantages Disadvantages
Extremely simple to implement Allows 2x burst at window boundaries
Memory efficient: O(1) per key Unfair: early arrivals in window get priority
Fast lookups (counter + timestamp) Not suitable for strict rate enforcement
Easy to visualize and explain Window reset causes traffic spikes
Interview Tip: Always mention the boundary problem when discussing fixed window. Interviewers want to see you understand the edge cases. Suggest sliding window counter as the fix.

Sliding Window Log

Track the timestamp of every request in a log. Most accurate but memory-intensive.

graph TB subgraph "Sliding Window Log: 5 req/min" A[Request Log
Sorted Timestamps] B{Count requests
in last 60 sec} C[12:00:15
12:00:42
12:01:03
12:01:28] D{Count < 5?} E[Allow & Add Timestamp] F[Reject 429] end A --> C C --> B B --> D D -->|Yes| E D -->|No| F E --> A

How It Works

  1. Store timestamp of every request in a sorted log (array or sorted set)
  2. On new request, remove timestamps older than window size
  3. Count remaining timestamps
  4. If count < limit, allow request and add new timestamp
  5. Otherwise reject

Example: 5 requests per minute

Current Time Log (timestamps in window) Count New Request Result
12:00:15 [12:00:15] 1 - Allow
12:00:42 [12:00:15, 12:00:42] 2 - Allow
12:01:03 [12:00:15, 12:00:42, 12:01:03] 3 - Allow
12:01:28 [12:00:42, 12:01:03, 12:01:28] 3 - Allow (12:00:15 expired)
12:01:55 [12:01:03, 12:01:28, 12:01:55] 3 Arrives Allow (count now 4)

Implementation Pseudocode

class SlidingWindowLog:
    def __init__(self, limit, window_size):
        self.limit = limit
        self.window_size = window_size  # seconds
        self.log = []  # sorted list of timestamps

    def allow_request(self):
        current_time = now()
        cutoff_time = current_time - self.window_size

        # Remove expired timestamps
        self.log = [ts for ts in self.log if ts > cutoff_time]

        if len(self.log) < self.limit:
            self.log.append(current_time)
            return True
        return False

Redis Implementation

-- Use sorted set with timestamps as scores
ZADD rate_limit:user123 {timestamp} {request_id}
ZREMRANGEBYSCORE rate_limit:user123 0 {cutoff_timestamp}
ZCARD rate_limit:user123  -- count requests in window
EXPIRE rate_limit:user123 {window_size + buffer}

Advantages & Disadvantages

Advantages Disadvantages
Perfectly accurate, no boundary issues High memory: O(N) per key where N = limit
True sliding window at millisecond precision Expensive cleanup on every request
Fair: respects exact time windows Slower than counter-based approaches
Can query exact request history Doesn't scale well to high limits (10K+ req/min)
Interview Tip: Sliding window log is the most accurate but least practical for large-scale systems. Mention it as a baseline for correctness, then suggest sliding window counter as the production-ready alternative.

Sliding Window Counter

Hybrid approach combining fixed window simplicity with sliding window accuracy. Best balance for production systems.

Algorithm

Estimate current window count using weighted sum of previous + current fixed windows.

graph LR subgraph "Sliding Window Counter" A[Previous Window
12:00:00-12:00:59
Count: 80] B[Current Window
12:01:00-12:01:59
Count: 40] C[Sliding Window
12:00:30-12:01:29
Weight prev: 50%
Weight curr: 50%] D[Estimate:
80 × 0.5 + 40 = 80] end A --> C B --> C C --> D

Formula

Current Time: 12:01:30 (50% through current window)
Window Size: 60 seconds
Limit: 100 requests

previous_window_count = 80
current_window_count = 40
overlap_percentage = (60 - 30) / 60 = 0.5

estimated_count = previous_window_count × overlap_percentage + current_window_count
                = 80 × 0.5 + 40
                = 40 + 40
                = 80

80 < 100 → Allow request

Step-by-Step Example

Current Time Prev Window Count Curr Window Count Overlap % Estimated Count Allow? (limit=100)
12:01:00 80 0 100% 80 × 1.0 + 0 = 80 Yes
12:01:15 80 20 75% 80 × 0.75 + 20 = 80 Yes
12:01:30 80 40 50% 80 × 0.5 + 40 = 80 Yes
12:01:45 80 80 25% 80 × 0.25 + 80 = 100 No (at limit)
12:02:00 100 0 100% 100 × 1.0 + 0 = 100 No

Implementation Pseudocode

class SlidingWindowCounter:
    def __init__(self, limit, window_size):
        self.limit = limit
        self.window_size = window_size
        self.windows = {}  # {window_id: count}

    def allow_request(self):
        current_time = now()
        current_window = current_time // self.window_size
        previous_window = current_window - 1

        current_count = self.windows.get(current_window, 0)
        previous_count = self.windows.get(previous_window, 0)

        # Calculate position in current window (0.0 to 1.0)
        elapsed_in_current = (current_time % self.window_size) / self.window_size
        previous_weight = 1.0 - elapsed_in_current

        estimated_count = previous_count * previous_weight + current_count

        if estimated_count < self.limit:
            self.windows[current_window] = current_count + 1
            return True
        return False

Redis Implementation

-- Store two counters: previous and current window
current_window = floor(timestamp / window_size)
previous_window = current_window - 1

current_key = "rate_limit:user123:" .. current_window
previous_key = "rate_limit:user123:" .. previous_window

current_count = GET current_key OR 0
previous_count = GET previous_key OR 0

elapsed_in_current = (timestamp % window_size) / window_size
estimated = previous_count * (1 - elapsed_in_current) + current_count

if estimated < limit then
    INCR current_key
    EXPIRE current_key (window_size * 2)
    return true
end
return false

Advantages & Disadvantages

Advantages Disadvantages
Near-perfect accuracy (small boundary issues) Approximation can allow slightly over limit
Memory efficient: O(1) per key (2 counters) Requires synchronized clocks in distributed systems
Fast: simple arithmetic, no log cleanup More complex than fixed window
Smooths traffic, reduces boundary bursts Edge cases at window transition moments
Interview Tip: Sliding window counter is the best general-purpose algorithm. It combines the efficiency of fixed window with the accuracy of sliding window log. Mention this as your default recommendation for large-scale systems.

Algorithm Comparison

Algorithm Accuracy Memory per Key Burst Handling Complexity Best Use Case
Token Bucket Good O(1)
2 numbers
Excellent
Immediate burst up to capacity
Low API rate limiting with burst tolerance
Leaky Bucket Good O(N)
Queue size
Poor
Constant output only
Medium Traffic shaping, constant processing rate
Fixed Window Poor
2x burst at boundaries
O(1)
1 counter
Poor
Unpredictable at boundaries
Very Low Internal services, non-critical limits
Sliding Window Log Perfect
Exact timestamps
O(N)
Store all requests
Fair
True sliding enforcement
High Low-traffic critical APIs, audit trails
Sliding Window Counter Excellent
~5% error
O(1)
2 counters
Good
Smooth distribution
Medium Large-scale APIs, multi-tenant SaaS

Performance Comparison (1M requests/sec)

Metric Token Bucket Fixed Window Sliding Window Log Sliding Window Counter
Memory (1K users) 32 KB 16 KB 800 MB 32 KB
CPU per Check ~50 ns ~30 ns ~10 Ξs ~100 ns
Latency p99 < 1 ms < 1 ms 5-10 ms < 1 ms
Boundary Burst Controlled by capacity Up to 2x limit None < 10% over limit

Decision Tree

Requirement Algorithm Choice Reason
Need to allow bursts Token Bucket Explicit burst capacity parameter
Must enforce constant rate Leaky Bucket Guaranteed smooth output
Millions of users, memory constrained Sliding Window Counter O(1) memory with good accuracy
Audit requirements, legal compliance Sliding Window Log Perfect accuracy, full history
Simple internal service Fixed Window Easiest to implement, good enough
Multi-tier API (free/pro/enterprise) Token Bucket Different burst capacities per tier
Real-time traffic shaping Leaky Bucket Network QoS, video streaming

Distributed Rate Limiting

In multi-server deployments, rate limiting must be coordinated to prevent over-provisioning.

Challenges

Challenge Description Impact
Race Conditions Multiple servers check limit simultaneously Over-limit by up to N × servers
Synchronization Latency Time to propagate counts across nodes Stale data allows more requests
Network Partitions Servers can't reach shared state store Fail open (allow all) or closed (reject all)?
Clock Skew Server clocks drift apart Window boundaries misaligned
Bottleneck Centralized state store becomes bottleneck Slower than single-server rate limiting

Redis-Based Solutions

Option 1: INCR + EXPIRE (Fixed Window)

-- Simple but has race condition between INCR and EXPIRE
key = "rate_limit:user123:" .. current_window
count = INCR key
if count == 1 then
    EXPIRE key window_size
end

if count <= limit then
    return true
else
    return false
end

Problem: If server crashes after INCR but before EXPIRE, key never expires.

Option 2: Lua Script (Atomic)

-- Atomic execution, no race conditions
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('GET', key)
if current == false then
    redis.call('SET', key, 1, 'EX', window)
    return 1
end

current = tonumber(current)
if current < limit then
    redis.call('INCR', key)
    return 1
else
    return 0
end

Option 3: Token Bucket with Redis

-- Store tokens and last_refill_time
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

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

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

if tokens >= 1 then
    tokens = tokens - 1
    redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', key, 3600)
    return 1
else
    return 0
end

Option 4: Sliding Window Counter with Redis

-- Most accurate distributed algorithm
local key_prefix = KEYS[1]
local limit = tonumber(ARGV[1])
local window_size = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local current_window = math.floor(now / window_size)
local previous_window = current_window - 1

local current_key = key_prefix .. ":" .. current_window
local previous_key = key_prefix .. ":" .. previous_window

local current_count = tonumber(redis.call('GET', current_key) or "0")
local previous_count = tonumber(redis.call('GET', previous_key) or "0")

local elapsed = (now % window_size) / window_size
local previous_weight = 1 - elapsed
local estimated = previous_count * previous_weight + current_count

if estimated < limit then
    redis.call('INCR', current_key)
    redis.call('EXPIRE', current_key, window_size * 2)
    return 1
else
    return 0
end

Strategies for Reducing Redis Load

Strategy Description Tradeoff
Local Cache + Sync Check local counter, sync to Redis periodically Faster but less accurate (over-limit by ~10%)
Sticky Sessions Route user to same server (local rate limiting) Uneven load distribution, server failures problematic
Per-Server Quota Divide limit by N servers (each enforces limit/N) Wastes capacity if traffic uneven across servers
Redis Pipeline Batch multiple checks into one round trip Reduced latency but still hits Redis
Redis Cluster Shard users across multiple Redis nodes More infrastructure complexity
Interview Tip: Always discuss tradeoffs. Perfect accuracy requires synchronization (Redis), but relaxed accuracy allows local caching for better performance. Ask the interviewer: "How critical is it to never exceed the limit, even by 1%?"

Handling Redis Failures

Strategy When to Use Consequence
Fail Open Availability > rate limiting (e.g., user-facing APIs) Allow all requests, no rate limiting
Fail Closed Security > availability (e.g., payment APIs) Reject all requests, service unavailable
Fallback to Local Best of both worlds Temporary over-limit, but service continues
Circuit Breaker Frequent Redis timeouts Stop checking Redis, fail open for N seconds

Rate Limiting Architecture

Rate limiting can be enforced at multiple layers for defense in depth.

graph TB A[Client] -->|1. Client-Side Limit
Prevent wasted requests| B[Load Balancer] B -->|2. L7 Load Balancer
DDoS protection| C[API Gateway] C -->|3. API Gateway
User/API key limits| D[Auth Service] C -->|3. API Gateway| E[Service A] C -->|3. API Gateway| F[Service B] D -->|4. Service-Level
Fine-grained limits| G[Database] E -->|4. Service-Level| G F -->|4. Service-Level| G G -->|5. Database
Query/connection limits| H[(Storage)] style C fill:#e1f5ff style G fill:#ffe1e1

Layer-by-Layer Breakdown

Layer Scope Purpose Limit Examples
1. Client-Side Per client instance Prevent wasted requests, improve UX 1 search request per 300ms, debounce
2. Load Balancer Per IP, global DDoS protection, syn flood prevention 10K req/sec per IP, connection limits
3. API Gateway Per user, per API key Enforce subscription tiers, abuse prevention Free: 100/hr, Pro: 10K/hr
4. Application Service Per endpoint, per operation Protect expensive operations 5 video encodes/min, 10 exports/day
5. Database Per query, per connection Prevent slow queries, connection pool exhaustion Max 1000 connections, query timeout 30s

Multi-Tenant SaaS Architecture

graph TB subgraph "Multi-Tenant Rate Limiting" A[API Gateway] --> B{Rate Limit Middleware} B --> C[Check Global Limit
100K req/sec system-wide] C -->|Pass| D[Check Tenant Limit
Tenant A: 10K req/min] D -->|Pass| E[Check User Limit
User 123: 100 req/min] E -->|Pass| F[Check Endpoint Limit
/export: 10 req/hr] F -->|Pass| G[Process Request] C -->|Fail| H[429: System Overload] D -->|Fail| I[429: Tenant Quota Exceeded] E -->|Fail| J[429: User Limit Reached] F -->|Fail| K[429: Endpoint Throttled] end

Hierarchical Rate Limiting

Apply multiple limits in order from broadest to most specific.

Priority Scope Limit Purpose
1 Global 100K req/sec System capacity limit
2 Per Tenant 10K req/min Prevent noisy neighbor
3 Per User 100 req/min Fair usage within tenant
4 Per API Endpoint /search: 20 req/min
/export: 5 req/hr
Protect expensive operations
5 Per Resource 10 concurrent uploads per user Prevent resource exhaustion
Interview Tip: Hierarchical limits are critical for multi-tenant systems. Apply limits in order: global → tenant → user → endpoint. This prevents both system overload and individual abuse.

HTTP Headers & Responses

Standard headers communicate rate limit status to clients.

Common Rate Limit Headers

Header Description Example Value
X-RateLimit-Limit Maximum requests allowed in window 1000
X-RateLimit-Remaining Requests left in current window 457
X-RateLimit-Reset Unix timestamp when limit resets 1672531200
X-RateLimit-Window Window size in seconds 60
Retry-After Seconds to wait before retrying (in 429 response) 42

Example Response Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 457
X-RateLimit-Reset: 1672531200
Content-Type: application/json

{
  "data": "..."
}

429 Too Many Requests Response

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

{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "You have exceeded the rate limit of 1000 requests per hour.",
    "retry_after": 42,
    "limit": 1000,
    "window": "1 hour",
    "documentation_url": "https://api.example.com/docs/rate-limits"
  }
}

Client-Side Handling

Strategy Implementation When to Use
Exponential Backoff Wait 1s, 2s, 4s, 8s between retries General-purpose retry logic
Respect Retry-After Read header, wait exact seconds specified When server provides Retry-After
Throttle Based on Remaining Slow down requests as Remaining → 0 Batch processing, background jobs
Pre-emptive Rate Limiting Client tracks own request count, pauses before hitting limit SDK/library for API
Circuit Breaker Stop retrying after N consecutive 429s Prevent thundering herd

Standard: IETF Draft

The IETF RateLimit Header Fields draft standardizes these headers:

HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 45
RateLimit-Reset: 42
RateLimit-Policy: 100;w=60
Interview Tip: Always include rate limit headers in responses, even on success (200 OK). Clients can use this to implement adaptive throttling and avoid hitting limits.

Design a Rate Limiter

End-to-end design for a multi-tenant SaaS with per-user, per-API, and global limits.

Requirements

Requirement Details
Scale 10M users, 100K requests/sec
Limits Per-user: 100 req/min
Per-API: varies by endpoint
Global: 100K req/sec
Accuracy Allow up to 10% over-limit acceptable
Latency Rate limit check < 5ms p99
Distributed 100 API servers across 3 regions

High-Level Architecture

graph TB A[Client] --> B[CDN
Static rate limit: 1K req/sec per IP] B --> C[Load Balancer
L4/L7 DDoS protection] C --> D[API Gateway Cluster
100 servers] D --> E[Rate Limit Middleware] E --> F{Check Local Cache} F -->|Cache Hit| G{Under Limit?} F -->|Cache Miss| H[Redis Cluster
Sliding Window Counter] H --> G G -->|Yes| I[Process Request] G -->|No| J[Return 429] I --> K[Application Services] subgraph "Redis Cluster" H L[(Shard 1
Users A-F)] M[(Shard 2
Users G-L)] N[(Shard 3
Users M-Z)] end H --> L H --> M H --> N style E fill:#e1f5ff style H fill:#ffe1e1

Data Model

Key Pattern Data Structure Value TTL
rl:user:{id}:{window} String (counter) Request count in window 2 × window size
rl:api:{endpoint}:{window} String (counter) Global endpoint count 2 × window size
rl:tenant:{id}:{window} String (counter) Tenant request count 2 × window size
rl:global HyperLogLog Approximate global count 60 seconds

Algorithm Choice

Sliding Window Counter for all limits (best balance of accuracy and performance).

Implementation Flow

function handle_request(user_id, api_endpoint):
    # 1. Check global limit (fastest path)
    if not check_global_limit():
        return 429_response("System overload")

    # 2. Check local cache (no network call)
    local_count = local_cache.get(user_id)
    if local_count and local_count > limit * 0.9:
        # Approaching limit, check Redis
        pass
    elif local_count and local_count < limit * 0.5:
        # Safely under limit, allow without Redis check
        local_cache.incr(user_id)
        return process_request()

    # 3. Check Redis (accurate distributed count)
    redis_result = redis_sliding_window_check(user_id, limit, window)
    if not redis_result.allowed:
        return 429_response(redis_result)

    # 4. Check endpoint-specific limit
    endpoint_result = check_endpoint_limit(api_endpoint)
    if not endpoint_result.allowed:
        return 429_response(endpoint_result)

    # 5. Update local cache
    local_cache.set(user_id, redis_result.count, ttl=60)

    # 6. Process request
    return process_request()

Optimizations

Optimization Technique Benefit
Local Cache In-memory LRU cache per API server (10K entries, 60s TTL) Reduce Redis calls by 80%
Lua Scripts Atomic sliding window counter in Lua 1 round trip instead of 5
Redis Pipelining Batch multiple user checks in one call Reduce latency by 50%
Sharding Hash user_id to Redis shard (consistent hashing) Horizontal scaling
Read Replicas Redis read replicas for geographically distant servers Lower cross-region latency
Lazy Cleanup TTL-based expiration, no manual deletion Avoid cleanup overhead

Failure Modes

Failure Strategy Impact
Redis Down Fail open with local cache limits (higher threshold) Temporary over-limit, but service available
Redis Slow Circuit breaker after 3 timeouts, fail open for 60s Avoid cascading failures
Cache Stampede Probabilistic early expiration (jittered TTL) Smooth cache refills
Clock Skew Use NTP, accept 10% accuracy loss Slight over-limit acceptable
Interview Tip: Walk through the request flow step-by-step. Mention optimizations (local cache, Lua scripts) and failure modes (Redis down → fail open). Discuss tradeoffs between accuracy and availability.

Throttling vs Rate Limiting vs Load Shedding

Related but distinct concepts for controlling system load.

Concept Definition Mechanism Example
Rate Limiting Cap number of requests per time window Count requests, reject after limit 100 requests per minute per user
Throttling Slow down requests to maintain steady rate Delay processing, queue requests Process 10 requests per second (queue the rest)
Load Shedding Drop requests when system is overloaded Monitor system health, shed lowest-priority traffic Reject 50% of non-critical requests when CPU > 80%
Backpressure Signal upstream to slow down TCP flow control, HTTP 503, async feedback Return 503 to load balancer to stop sending traffic
Circuit Breaking Stop calling failing downstream service Detect failures, open circuit, fail fast Stop calling payment API after 5 consecutive errors

When to Use Each

Scenario Technique Reason
Prevent abuse, enforce quotas Rate Limiting Clear per-user limits
Downstream can't handle bursts Throttling Smooth out traffic spikes
System under unexpected load Load Shedding Protect critical services
Cascading failure prevention Circuit Breaking Fail fast, don't overwhelm failing service
Producer faster than consumer Backpressure Signal upstream to slow down

Comparison: Rate Limiting vs Throttling

Property Rate Limiting Throttling
Response to Excess Reject immediately (429) Queue and delay
User Experience Fail fast, retry later Slower responses, no errors
Resource Usage Low (just counting) Higher (queue management)
Algorithm Token bucket, sliding window Leaky bucket, queue
Typical Use API quotas, abuse prevention Traffic shaping, smooth load

Load Shedding Strategies

Strategy Description Example
Random Shedding Drop X% of requests randomly Drop 30% of requests when CPU > 80%
Priority-Based Preserve high-priority traffic Keep paid users, shed free tier
Graceful Degradation Disable non-essential features Disable recommendations, keep checkout working
LIFO (Latest In, First Out) Drop newest requests first Process older requests first (fairness)
Adaptive Increase shed percentage as load grows CPU 70% → 10% shed, 80% → 30%, 90% → 50%
Interview Tip: Emphasize the distinction: rate limiting is proactive (prevent abuse), throttling is reactive (smooth bursts), and load shedding is defensive (survive overload). Systems often use all three together.

Summary