Interview Setup
Interview Prompt
Design a distributed API rate limiter. Clients send HTTP requests, and the system enforces per-tenant limits such as 1,000 requests per minute per API key, returning HTTP 429 when limits are exceeded. Support multiple limit dimensions and configurable rules.
Clarifying Questions (ask before designing)
| Question | Why it matters |
|---|---|
| Do we enforce hard limits or permit controlled burst traffic? | A token bucket allows short traffic bursts up to capacity while bounding the long-term average rate, whereas a fixed window counter is simpler but produces boundary traffic spikes at window rollovers. |
| Accuracy vs memory overhead: do we require exact rolling counts or an approximate counter? | A sliding window log provides exact rolling-window counts against the authoritative store with O(N) memory proportional to active requests, whereas a sliding window counter provides an O(1) memory approximation using time-weighted interpolation. |
| Should enforcement live at the API gateway, in service mesh sidecars, or within individual microservices? | An API gateway provides a single centralized enforcement point, whereas application-layer enforcement is required for computationally expensive endpoints with variable resource costs. |
| Are rate limits scoped to a single region or enforced globally across multiple datacenters? | Global enforcement can use centrally reserved regional budgets (bounding total usage without runtime sync) or asynchronous reconciliation with defined overshoot budgets, whereas routing all traffic to a centralized Redis deployment adds cross-region WAN latency. |
Scope
In scope
- Per-API-key, per-user, and per-IP rate limits
- Configurable rules for requests per second, daily quotas, and burst allowances
- Distributed enforcement across horizontally scaled gateway instances
- HTTP 429 responses with standardized Retry-After and rate limit headers
- Administrative API to create, update, and manage rate limit rules dynamically
Out of scope (state explicitly)
- Volumetric Layer 3 and Layer 4 DDoS mitigation (handled at the network edge via WAF and anycast routing)
- Billing metering and financial invoicing for usage-based monetization
- OAuth token generation and identity provider authentication flows
Functional Requirements
Start by clarifying which rate limits matter most for the scenario. You will typically focus on per-client throttling, configurable rules, and clear HTTP 429 responses, while client tiers and administrative dashboards serve as common follow-up topics.
- Limit the number of requests a client can send to an API within a defined time window.
- Support multiple rate limiting dimensions, including per user, per IP address, per API key, and per endpoint.
- Support configurable rules based on request counts across seconds, minutes, hours, or days.
- Return informative error responses with HTTP status 429 and a
Retry-Afterheader when limits are exceeded. - Support distinct rate limiting tiers such as free, premium, and enterprise plans.
- Provide allowlist capabilities for trusted internal microservices and health check probes.
- Support hard limits that reject excess requests as well as soft limits that log warnings without blocking traffic.
- Provide an administrative dashboard to monitor rate limiting metrics and adjust rule thresholds in real time.
Non-Functional Requirements
Rate limiting sits directly on every synchronous request path, so latency overhead and fail-open vs fail-closed behaviors are the primary design priorities.
- Ultra-Low Latency: The rate limit check must add less than 1 millisecond of latency overhead to each incoming request.
- High Availability: Maintain 99.999% availability at the decision layer, using endpoint-tier degradation (fail-closed for security-critical endpoints, bounded local fallback for general APIs) during Redis failovers.
- Distributed Enforcement: Enforce limits accurately across multiple application servers and datacenters for global rate limiting.
- High Accuracy: Maintain strict counting accuracy on authoritative Redis shards, avoiding read-modify-write race conditions.
- Scalability: Support peak loads of 1 million requests per second across millions of unique clients.
- Fault Tolerance: Degrade gracefully when the rate limiting storage backend becomes unavailable.
- Atomic Operations: Ensure check-and-increment operations execute atomically to eliminate race conditions under concurrent traffic.
Capacity Estimations
This capacity estimation separates client-side network round trips from internal Redis engine operations, and demonstrates how memory overhead varies by algorithm.
| Metric | Calculation | Value |
|---|---|---|
| Aggregate API traffic | Given peak design throughput | 1,000,000 RPS |
| Active client API keys | Given registered client base | 10,000,000 keys |
| Rate limit rule configurations | Given tier and endpoint policies | ~100 rules |
| Token bucket memory per key | Redis hash (tokens, timestamp, metadata) | ~120 bytes |
| Active working set memory (1M active keys) | 1M active keys x 120 bytes x 3 rules | ~360 MB |
| Sliding window log memory (100 req/min) | 1M active keys x 100 entries x 64 bytes in ZSET | ~6.4 GB (workload dependent) |
| Client-visible Redis check invocations | 1 check per incoming request (EVALSHA) | 1,000,000 checks/sec |
| Internal Redis engine operations | 1M checks x ~2-3 internal commands per Lua script | ~2M-3M internal ops/sec |
For token bucket and sliding window counters, rate limiting metadata consumes minimal memory (~120 bytes per key, totaling ~360 MB for 1 million active keys across 3 rules). In contrast, exact sliding window logs scale memory with admitted request volume (100 requests per key in a ZSET requires ~6.4 KB per key, totaling ~6.4 GB for 1 million active keys). Centralized Redis Lua executes as 1 client network round trip (EVALSHA) per incoming request, translating to ~2M to 3M internal Redis commands per second distributed across cluster shards.
Architecture Diagram
During an interview, clarify fail-open vs fail-closed expectations before finalizing your Redis design because payment and authentication endpoints typically require fail-closed handling.
Follow the flow request by request. The gateway middleware intercepts every incoming call, retrieves matching rules from a local in-memory cache, and executes an atomic check in Redis before forwarding allowed traffic to backend services.
Component Deep Dives
Rate Limiter Middleware
The middleware layer intercepts incoming traffic to enforce rate limits before requests reach backend services, making placement between the API gateway and sidecar proxies a key design decision.
- Placement: Operates as an API Gateway filter or as an Envoy sidecar proxy.
- Centralized Enforcement: Every request passes through the middleware before reaching downstream microservices.
- Execution Flow:
- Extract the client identifier such as the API key, user ID, or IP address from the request headers.
- Retrieve applicable rate limit rules from the local in-memory cache, which periodically refreshes from the Rules DB every 30 seconds.
- Execute the token bucket Lua script in Redis as an atomic read-modify-write operation, avoiding race conditions that occur with separate GET and INCR calls.
- If the request is under the limit, allow it to proceed and attach the
X-RateLimit-Remainingheader to the response. - If the request exceeds the limit, reject it immediately with an HTTP
429 Too Many Requestsstatus code and aRetry-Afterheader.
Redis Cluster (Rate State Store)
We store rate counters in Redis to achieve sub-millisecond atomic read-modify-write operations without incurring multiple network hops per request.
- Why Redis:
- In-memory storage delivers sub-millisecond check latency.
- Lua scripts execute atomically on the target shard, running the entire check and decrement logic as a single operation.
- Built-in key TTLs automatically expire stale counter keys.
- The single-threaded execution model per shard eliminates concurrency lock contention.
- Deployment Topology: Redis Cluster configured with 6 nodes (3 primaries and 3 replicas) across availability zones for high availability.
- Data Model: Keys follow the format
ratelimit:{client_id}:{endpoint}:{window}with an integer counter value.
Rules Configuration Service
Rate limiting rules change infrequently but must propagate rapidly across all gateway instances, which we achieve through local caching combined with Kafka event updates.
- Service Purpose: Provides an administrative API and management UI to create, update, and deactivate rate limiting rules.
- Persistence and Caching: Persists rule definitions in MySQL and caches active rules in memory on each API Gateway node.
- Rule Schema: Defined using YAML or JSON specifications that map limits per client tier and endpoint pattern. Validates that
capacity > 0andrefill_rate > 0before persisting. - Push-Based Updates: Emits rule change events to a Kafka topic so all gateway nodes immediately refresh their local cache.
Rate Limiting Algorithms: Deep Dive
Compare these five foundational rate limiting algorithms to justify your selection based on burst tolerance, memory overhead, and computational complexity.
Algorithm 1: Token Bucket (Recommended for Burst Tolerance)
How it works:
- Each client is assigned a bucket with a maximum capacity of
Btokens. - Tokens replenish at a constant rate of
Rtokens per second based on elapsed wall time. - Each incoming request consumes a defined cost (typically 1 token).
- If the bucket contains sufficient tokens, they are decremented and the request is admitted. Otherwise, the request is rejected with HTTP 429.
Pros: Accommodates short traffic bursts up to bucket capacity while maintaining a smooth average throughput bounded by the refill rate.
Cons: Requires tuning two interrelated parameters, including bucket capacity and refill rate.
Atomic Lua Script Implementation:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2]) -- tokens added per second (must be > 0)
local requested_cost = tonumber(ARGV[3]) or 1
-- For simplicity, current time is supplied by the caller.
-- In multi-gateway deployments where clock skew matters,
-- use Redis TIME or another trusted synchronized clock source.
local now = tonumber(ARGV[4]) -- epoch timestamp in seconds
-- Defensive configuration and parameter validation
if capacity == nil or capacity <= 0 then
return redis.error_reply("invalid rate limit configuration: capacity must be > 0")
end
if refill_rate == nil or refill_rate <= 0 then
return redis.error_reply("invalid rate limit configuration: refill_rate must be > 0")
end
if requested_cost == nil or requested_cost <= 0 then
return redis.error_reply("invalid request cost: requested_cost must be > 0")
end
if now == nil or now <= 0 then
return redis.error_reply("invalid timestamp")
end
-- Invariant: requested cost cannot exceed maximum bucket capacity
if requested_cost > capacity then
return {0, 0, -1} -- [0 = rejected, remaining = 0, retry_after = -1 (permanently impossible under policy)]
end
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1])
local last_refill = tonumber(data[2])
if tokens == nil or last_refill == nil then
-- Initialize fresh bucket to full capacity
tokens = capacity
last_refill = now
else
-- Refill tokens based on elapsed wall time
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
last_refill = now
end
if tokens >= requested_cost then
tokens = tokens - requested_cost
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
-- Idle-state cleanup TTL: time required to refill from empty to full plus a safety margin
local refill_duration = math.ceil(capacity / refill_rate)
local ttl = math.max(refill_duration * 2, 60)
redis.call('EXPIRE', key, ttl)
return {1, math.floor(tokens), 0} -- [1 = allowed, remaining_tokens, retry_after = 0]
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
local tokens_needed = requested_cost - tokens
local retry_after = math.ceil(tokens_needed / refill_rate)
return {0, math.floor(tokens), retry_after} -- [0 = rejected, remaining_tokens, retry_after_seconds]
endAlgorithm 2: Sliding Window Log (Exact Rolling Window)
How it works:
- Stores the timestamp of every admitted request in a Redis sorted set (ZSET).
- The active rolling window interval is defined as
(now - window, now], meaning any event timestamp older than or equal tonow - windowis expired. - When a new request arrives, removes all timestamps in
[0, now - window]. - Counts surviving entries in the ZSET. If
count ≥ limit, the request is rejected andRetry-Afteris calculated from the oldest surviving entry. The rejected request is not inserted into the set. - If
count < limit, the request is inserted with a unique member identifier (now:request_id) and admitted.
Pros: Exact rolling-window enforcement against the authoritative limiter state with zero boundary anomaly issues.
Cons: High memory overhead (O(N) entries per active client) because every admitted request timestamp is stored in memory. The exact sliding-window-log implementation shown here assumes unit-cost requests. Supporting weighted request costs would require storing the weight of each event or expanding one logical request into multiple units, which changes the data model and memory characteristics.
Atomic Lua Script Implementation:
local key = KEYS[1]
-- For simplicity, current time is supplied by the caller in milliseconds.
-- In multi-gateway deployments where clock skew matters, use a synchronized clock source.
local now = tonumber(ARGV[1]) -- current timestamp in milliseconds
local window = tonumber(ARGV[2]) -- window duration in milliseconds
local limit = tonumber(ARGV[3]) -- maximum allowed requests in window (unit-cost requests)
local request_id = ARGV[4] -- unique request identifier
-- Defensive argument validation
if now == nil or window == nil or limit == nil or window <= 0 or limit <= 0 or request_id == nil then
return redis.error_reply("invalid sliding window log arguments")
end
-- Active rolling window interval is defined as (now - window, now].
-- Events with timestamp <= cutoff are strictly expired.
local cutoff = now - window
-- 1. Remove all entries older than or equal to the rolling window cutoff
redis.call('ZREMRANGEBYSCORE', key, 0, cutoff)
-- 2. Count currently admitted requests surviving in the active (now - window, now] interval
local count = redis.call('ZCARD', key)
-- 3. Check capacity before admitting (unit-cost enforcement)
if count >= limit then
-- Find oldest surviving admitted request to calculate precise Retry-After
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local oldest_ts = tonumber(oldest[2])
local retry_after_ms = math.max(1, (oldest_ts + window) - now)
local retry_after_sec = math.ceil(retry_after_ms / 1000)
return {0, count, retry_after_sec} -- [0 = rejected, current_count, retry_after_seconds]
else
-- 4. Admitted: add exactly one unique member with score = now
local member = now .. ":" .. request_id
redis.call('ZADD', key, now, member)
local ttl_sec = math.max(math.ceil((window / 1000) * 2), 60)
redis.call('EXPIRE', key, ttl_sec)
return {1, limit - count - 1, 0} -- [1 = allowed, remaining_quota, retry_after = 0]
endAlgorithm 3: Sliding Window Counter (Memory-Efficient Approximation)
How it works:
- Combines previous and current fixed window counters using a time-weighted sliding overlap.
- Calculates
estimated_before = prev_window_count * ((window - elapsed_seconds) / window) + current_window_count. - Admit the request if
estimated_before + request_cost ≤ limit, then increment the current bucket. Returns an estimated remaining quota (conservative integer floor). Otherwise, reject with HTTP 429.
Pros: Extremely memory efficient using only two integer counters per key while eliminating window boundary spikes.
Cons: Uses a linear interpolation approximation that assumes requests in the previous window were distributed uniformly. While the Lua script executes atomically, the algorithm itself remains an approximation of rolling-window usage, so remaining capacity and Retry-After are conservative estimates.
Atomic Lua Script Implementation:
local current_key = KEYS[1]
local previous_key = KEYS[2]
local limit = tonumber(ARGV[1])
local window_size = tonumber(ARGV[2]) -- window size in seconds (e.g., 60)
-- For simplicity, current elapsed seconds supplied by caller.
local current_second = tonumber(ARGV[3]) -- seconds elapsed in current window (0 to window_size - 1)
local requested_cost = tonumber(ARGV[4]) or 1
-- Defensive parameter validation
if limit == nil or limit <= 0 or window_size == nil or window_size <= 0 or current_second == nil or requested_cost == nil or requested_cost <= 0 then
return redis.error_reply("invalid sliding window counter arguments")
end
local prev_count = tonumber(redis.call('GET', previous_key)) or 0
local curr_count = tonumber(redis.call('GET', current_key)) or 0
-- Time-weighted linear approximation of requests admitted in the rolling window
local weight = (window_size - current_second) / window_size
local estimated_before = prev_count * weight + curr_count
-- Check if admitting this request exceeds the configured limit
if estimated_before + requested_cost > limit then
-- Retry-After is a conservative approximation: seconds until current window rolls over
-- (Actual capacity continuously decays as the previous window weight decreases)
local retry_after = math.max(1, window_size - current_second)
return {0, math.floor(estimated_before), retry_after} -- [0 = rejected, estimated_count, retry_after]
else
local new_count = redis.call('INCRBY', current_key, requested_cost)
if new_count == requested_cost then
redis.call('EXPIRE', current_key, window_size * 2)
end
-- Estimated remaining capacity (conservative integer floor approximation to avoid overstating quota)
local estimated_remaining = math.max(0, math.floor(limit - (estimated_before + requested_cost)))
return {1, estimated_remaining, 0} -- [1 = allowed, estimated_remaining, 0]
endAlgorithm 4: Fixed Window Counter
How it works:
- Increments an integer counter mapped to a discrete time block (such as 10:00 to 10:01).
- If the counter exceeds the limit, subsequent requests within that window are rejected.
Pros: Simple to implement with minimal memory usage.
Cons: Suffers from boundary burst spikes, where traffic surges at 10:00:59 and 10:01:01 can allow up to twice the configured limit within two seconds.
Algorithm 5: Leaky Bucket
How it works:
- Incoming requests enter a FIFO buffer queue.
- Requests are processed and released to backend servers at a constant, fixed rate.
- If the queue is full, new requests are dropped immediately.
Pros: Produces a strictly smooth, constant output rate regardless of traffic spikes.
Cons: Sudden bursts fill the queue, causing queue latency and potential client timeouts.
Algorithm Recommendation Matrix
| Use Case | Recommended Algorithm | Key Rationale |
|---|---|---|
| General API rate limiting | Token Bucket | Permits controlled bursts up to bucket capacity while bounding average long-term throughput |
| Smooth rolling limits with low memory | Sliding Window Counter | O(1) memory per key that eliminates fixed-window boundary spikes via time-weighted approximation |
| Strict rolling-window compliance | Sliding Window Log | Provides exact rolling-window enforcement against the authoritative state without approximation, at the cost of O(N) memory |
| Downstream traffic smoothing | Leaky Bucket | Buffers burst traffic in a queue and drains to backend workers at a constant rate |
Event Bus Design (Kafka)
Kafka distributes configuration updates asynchronously and captures HTTP 429 violation events for security auditing without adding latency to the synchronous request path.
# Kafka Event Bus Topology for Rules Distribution and Violation Auditing
topics:
rate-rules-updates:
partitions: 8 # low volume for configuration updates
partition_key: rule_id
retention: 7 days
replication_factor: 3
min_insync_replicas: 2
producer: "Rules Configuration Service (triggers on admin CRUD operations)"
consumer_group: "gateway-rule-sync"
consumer_behavior:
- "Each API Gateway or sidecar proxy refreshes its local in-memory rules cache"
- "Message payload contains monotonically increasing rule versions to ignore stale updates"
- "Lag alerting triggers if consumer lag exceeds 30 seconds (SLO breach)"
rate-limit-violations:
partitions: 64
partition_key: client_id # preserves per-client ordering for audit trails
retention: 90 days # compliance and abuse investigations
replication_factor: 3
min_insync_replicas: 2
producer: "Rate Limiter Middleware (asynchronous fire-and-forget on HTTP 429)"
event_schema:
event_id: "uuid"
client_id: "string"
endpoint: "string"
tier: "string"
limit: "integer"
window_seconds: "integer"
retry_after_seconds: "integer"
timestamp: "epoch_seconds"
consumer_groups:
abuse-detector: "Flags clients exceeding rate limits repeatedly for automated key suspension or WAF IP blocks"
analytics-sink: "Streams violation metrics to ClickHouse dashboards for rate limit visibility and capacity planning"
dead_letter_queue: "rate-limit-violations-dlq (routes events after 3 failed processing attempts)"
execution_paths:
synchronous_path: "Client request -> extract client identifier -> run Lua check in Redis -> return 200 or 429 (< 1ms)"
asynchronous_path: "Rule configuration updates propagate via Kafka, while HTTP 429 audit events never block request traffic"API Design
The rate limiter interface consists of internal middleware check contracts, administrative rule management endpoints, and standard HTTP response headers for client feedback.
Check Rate Limit (Internal Middleware Signature)
// Internal middleware contract executed on every API request
interface RateLimitRequest {
clientId: string;
endpoint: string;
timestamp: number;
cost?: number;
}
interface RateLimitResult {
allowed: boolean;
remaining: number;
retryAfterSeconds: number;
limit: number;
resetTimestamp: number;
}
function checkRateLimit(request: RateLimitRequest): Promise<RateLimitResult>;Get Rate Limit Status
GET /api/v1/rate-limit/status
Authorization: Bearer <token>
Response: 200 OK
{
"client_id": "api_key_123",
"limits": [
{
"endpoint": "/api/v1/search",
"limit": 100,
"window": "1m",
"remaining": 42,
"resets_at": "2026-03-13T10:01:00Z"
}
]
}Standard Rate Limit Response Headers
RateLimit-Limit: 100
RateLimit-Remaining: 42
RateLimit-Reset: 1710320460
RateLimit-Policy: 100;w=60
# Common legacy headers for backwards compatibility:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1710320460Rejected Request Response (HTTP 429)
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1710320460
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710320460
{
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded. Please retry after 30 seconds."
}Configure Rate Limit Rule (Admin Endpoint)
POST /api/v1/admin/rate-rules
Content-Type: application/json
{
"rule_id": "search_free_tier",
"client_tier": "free",
"endpoint": "/api/v1/search",
"max_requests": 100,
"window_seconds": 60,
"algorithm": "sliding_window_counter"
}
Data Model
The data architecture pairs high-throughput in-memory Redis counters with persistent MySQL storage for rule definitions and tier configurations.
Redis: Token Bucket Hash State
Key Format: ratelimit:{client_id}:{endpoint}:token
Value: Hash { "tokens": float, "last_refill": epoch_seconds }
TTL: max(ceil((capacity / refill_rate) * 2), 60)Redis: Sliding Window Counter Keys
Key Format: ratelimit:{client_id}:{endpoint}:{window_start_epoch}
Value: Integer counter (incremented via INCRBY)
TTL: 2 * window_seconds (retains previous window for weighted calculation)
Example Keys:
ratelimit:user_123:/api/v1/search:1710320400 -> 45
ratelimit:user_123:/api/v1/search:1710320460 -> 12Redis: Sliding Window Log (ZSET)
Key Format: ratelimit:{client_id}:{endpoint}:log
Value: Sorted Set (Score = timestamp_ms, Member = timestamp_ms:request_id)
TTL: 2 * window_secondsMySQL: Rate Limit Rules Table
CREATE TABLE rate_limit_rules (
rule_id VARCHAR(64) PRIMARY KEY,
client_tier ENUM('free', 'premium', 'enterprise', 'internal'),
endpoint_pattern VARCHAR(256) NOT NULL, -- regex or path prefix: /api/v1/search.*
max_requests INT NOT NULL,
window_seconds INT NOT NULL,
algorithm ENUM('token_bucket', 'sliding_window_counter', 'sliding_window_log') NOT NULL,
action ENUM('reject', 'log_only', 'throttle') DEFAULT 'reject',
version INT UNSIGNED DEFAULT 1,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_tier_endpoint (client_tier, endpoint_pattern)
);MySQL: Client Tier Configurations Table
CREATE TABLE client_tiers (
client_id VARCHAR(128) PRIMARY KEY,
tier ENUM('free', 'premium', 'enterprise', 'internal') NOT NULL,
custom_limits JSON, -- specific rule overrides for this client
whitelisted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Kafka: Event Bus Topic Schemas
# Kafka Event Bus Topology for Rules Distribution and Violation Auditing
topics:
rate-rules-updates:
partitions: 8 # low volume for configuration updates
partition_key: rule_id
retention: 7 days
replication_factor: 3
min_insync_replicas: 2
producer: "Rules Configuration Service (triggers on admin CRUD operations)"
consumer_group: "gateway-rule-sync"
consumer_behavior:
- "Each API Gateway or sidecar proxy refreshes its local in-memory rules cache"
- "Message payload contains monotonically increasing rule versions to ignore stale updates"
- "Lag alerting triggers if consumer lag exceeds 30 seconds (SLO breach)"
rate-limit-violations:
partitions: 64
partition_key: client_id # preserves per-client ordering for audit trails
retention: 90 days # compliance and abuse investigations
replication_factor: 3
min_insync_replicas: 2
producer: "Rate Limiter Middleware (asynchronous fire-and-forget on HTTP 429)"
event_schema:
event_id: "uuid"
client_id: "string"
endpoint: "string"
tier: "string"
limit: "integer"
window_seconds: "integer"
retry_after_seconds: "integer"
timestamp: "epoch_seconds"
consumer_groups:
abuse-detector: "Flags clients exceeding rate limits repeatedly for automated key suspension or WAF IP blocks"
analytics-sink: "Streams violation metrics to ClickHouse dashboards for rate limit visibility and capacity planning"
dead_letter_queue: "rate-limit-violations-dlq (routes events after 3 failed processing attempts)"
execution_paths:
synchronous_path: "Client request -> extract client identifier -> run Lua check in Redis -> return 200 or 429 (< 1ms)"
asynchronous_path: "Rule configuration updates propagate via Kafka, while HTTP 429 audit events never block request traffic"Fault Tolerance
Designing resilient failure handling is critical because rate limiting operates on every synchronous API request path.
General Resilience Strategies
| Strategy | Operational Details |
|---|---|
| Endpoint-Tier Degradation | Authentication and payment endpoints fail closed (or enforce conservative local circuit-breaker caps) to prevent abuse, while general read APIs fail open with local in-memory limits to preserve availability. |
| Bounded Local Fallback | If Redis is unreachable, each API Gateway instance falls back to a local in-memory token bucket (e.g., 100 RPS ceiling) to prevent total downstream overload while preserving availability. |
| Redis Cluster High Availability | Deploy Redis Cluster with 3 primaries and 3 read replicas across availability zones, ensuring automatic replica promotion to primary in under 15 seconds upon node failure. |
| Circuit Breaker Protection | If Redis p99 latency exceeds 5 milliseconds across 10 consecutive probes, trip the circuit breaker and switch to local in-memory counting until Redis latency stabilizes. |
Problem-Specific Failure Scenarios
1. Race Conditions in Distributed Deployments
- Multiple gateway nodes attempt to increment the same counter concurrently under high traffic.
- Solution: Execute check-and-decrement logic within atomic Redis Lua scripts on a single-threaded Redis shard, eliminating read-modify-write races in a single round trip. In Redis Cluster, multi-key operations require hash tags like
{client_id}:keyto ensure shard co-location.
2. Clock Skew Across Distributed Nodes
- Node clock drift across gateway instances can cause inconsistent window boundary calculations and refill errors.
- Solution: For strict temporal consistency, have the Lua script call
redis.call('TIME')as the authoritative centralized clock rather than relying entirely on local gateway system timestamps.
3. Redis Failover and Replica Lag
- When a primary Redis node fails, an asynchronous replica promoted to primary may lack the most recent writes.
- Impact and Mitigation: Temporary over-admission during the brief 15-second failover window is accepted as a standard trade-off to prevent blocking legitimate API traffic. Stale replicas are never used for read-only rate enforcement because rate limiting requires atomic write authority.
4. Global Rate Limiting Across Multiple Datacenters
- A client with a 100 requests per minute quota making requests across US and EU datacenters could consume up to 200 requests per minute if counters are isolated.
- Architectural Solutions:
- Centralized Global Redis: All regions route rate checks to a single global Redis cluster, providing globally consistent enforcement against that single authority, but adding 50ms to 150ms cross-region WAN latency.
- Preallocated Regional Quota Budgets: Partition the global quota across datacenters (for example, US=500, EU=300, APAC=200 for a 1,000 global limit) such that the sum of regional budgets does not exceed the global limit. This guarantees total admitted traffic stays within the global quota without runtime synchronization, but risks stranding unused capacity in low-traffic regions.
- Asynchronous Synchronization with Delta Counters: Each region enforces a local quota partition and periodically synchronizes delta counts via Kafka or background gossip. This model is eventually consistent, and temporary over-admission is bounded by aggregate admitted traffic across regions during the unsynchronized window.
- Geo-DNS Sticky Routing: Route each client deterministically to a specific datacenter, keeping per-client state local while simplifying cluster topology.
- Recommendation: Use sticky routing for typical web services, preallocated budgets when global caps must be strictly bounded without cross-region WAN hops, and asynchronous budget synchronization for globally distributed API platforms.
5. Hot Keys Caused by Viral or Malicious Clients
- A single high-volume API key sending millions of requests per second can overload a single Redis hash slot.
- Solution: Composite key sharding distributes normal traffic across shards. For extreme hot keys, introduce centrally reserved token leasing: gateway instances atomically reserve a lease batch (such as 20 tokens) from the global Redis bucket, consume them locally in memory, and request a new batch when depleted. Because lease tokens are atomically reserved from the authoritative global bucket before local consumption, serving requests from a valid lease does not itself over-admit the globally reserved quota. The primary trade-off is temporarily stranded unused capacity if a gateway crashes before consuming its lease.
Additional Considerations
Multi-Tiered Rate Limiting Architecture
A defense-in-depth approach enforces rate limiting across four distinct infrastructure layers to protect backend resources at different granularities.
Level 1: Network and IP Layer -> Nginx connection limits, iptables, and WAF rules Level 2: API Gateway Layer -> Per-API-key and per-endpoint throttling (this design) Level 3: Application Layer -> Per-user business quotas (such as max 10 posts per day) Level 4: Infrastructure Layer -> Circuit breakers, thread pool bulkheads, and backpressure
Distributed Rate Limiting with Redis Lua Script
This atomic Lua script checks and decrements the token bucket state within a single Redis round trip.
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2]) -- tokens added per second (must be > 0)
local requested_cost = tonumber(ARGV[3]) or 1
-- For simplicity, current time is supplied by the caller.
-- In multi-gateway deployments where clock skew matters,
-- use Redis TIME or another trusted synchronized clock source.
local now = tonumber(ARGV[4]) -- epoch timestamp in seconds
-- Defensive configuration and parameter validation
if capacity == nil or capacity <= 0 then
return redis.error_reply("invalid rate limit configuration: capacity must be > 0")
end
if refill_rate == nil or refill_rate <= 0 then
return redis.error_reply("invalid rate limit configuration: refill_rate must be > 0")
end
if requested_cost == nil or requested_cost <= 0 then
return redis.error_reply("invalid request cost: requested_cost must be > 0")
end
if now == nil or now <= 0 then
return redis.error_reply("invalid timestamp")
end
-- Invariant: requested cost cannot exceed maximum bucket capacity
if requested_cost > capacity then
return {0, 0, -1} -- [0 = rejected, remaining = 0, retry_after = -1 (permanently impossible under policy)]
end
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1])
local last_refill = tonumber(data[2])
if tokens == nil or last_refill == nil then
-- Initialize fresh bucket to full capacity
tokens = capacity
last_refill = now
else
-- Refill tokens based on elapsed wall time
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
last_refill = now
end
if tokens >= requested_cost then
tokens = tokens - requested_cost
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
-- Idle-state cleanup TTL: time required to refill from empty to full plus a safety margin
local refill_duration = math.ceil(capacity / refill_rate)
local ttl = math.max(refill_duration * 2, 60)
redis.call('EXPIRE', key, ttl)
return {1, math.floor(tokens), 0} -- [1 = allowed, remaining_tokens, retry_after = 0]
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
local tokens_needed = requested_cost - tokens
local retry_after = math.ceil(tokens_needed / refill_rate)
return {0, math.floor(tokens), retry_after} -- [0 = rejected, remaining_tokens, retry_after_seconds]
endMonitoring, Metrics, and Alerting
Track these operational metrics to detect service degradation and identify abusive client patterns.
- Request Admission Ratio: Track the ratio of allowed requests to HTTP 429 rejections to detect abnormal traffic spikes.
- Gateway Check Latency: Monitor p50, p95, and p99 latency overhead added by the rate limiter against the 1-millisecond SLO.
- Redis Resource Utilization: Track memory consumption, CPU utilization per shard, and commands-per-second throughput.
- Client Limit Saturation: Alert when specific clients repeatedly exceed 90% of their allocated rate limits for sales upgrade opportunities or abuse mitigation.
- Fallback Activation Metrics: Track the frequency and duration of local in-memory fallback activations during Redis failover events.
Related Problems and Concepts
Rate limiting shares atomic state update patterns with Like Count (High-Profile) for distributed counters and Flash Sale System for Redis Lua inventory decrement logic. Architectural placement and middleware trade-offs closely mirror API Gateway (Kong). You can also explore foundational primitives in Redis Patterns for Interview Systems, Consistent Hashing, and Back-of-the-Envelope Estimation.
Interview Walkthrough
- 25-Minute Interview Strategy
Focus on algorithm selection and Redis Lua atomicity before diving into multi-region synchronization unless staff depth is requested.
- Functional and Non-Functional Requirements (3 min)
- Capacity Calculations and Throughput Math (4 min)
- Algorithm Selection and Trade-Off Analysis (6 min)
- Redis Cluster Architecture and Lua Atomicity (7 min)
- Fault Tolerance and Failover Strategy (5 min)
- Begin by clarifying limit dimensions, including per-API-key, per-IP, per-endpoint, and client tier rules, before selecting a specific algorithm.
- Compare token bucket against sliding window counters, justifying your choice based on whether the API requires burst tolerance or strict rolling-window enforcement.
- Reference Redis Patterns for Interview Systems to explain how Redis Lua scripts guarantee atomic check-and-decrement operations across distributed gateway nodes.
- Discuss architectural placement by comparing centralized API gateway enforcement against application-layer middleware and sidecar proxies.
- Address the distributed counter synchronization challenge, explaining why sticky sessions are insufficient and how shared Redis state prevents quota multiplication.
- Define the client response contract explicitly, specifying HTTP 429 status codes,
Retry-Afterintervals, and standard rate limit response headers. - Quantify operational overhead using Back-of-the-Envelope Estimation to prove that p99 latency overhead remains under 1 millisecond at 1 million requests per second.
- Highlight the common pitfall of relying on in-process counters across horizontally scaled fleets, which inadvertently multiplies total granted quota by the instance count.
- State the fail-open vs fail-closed policy clearly, noting that general APIs default to fail-open with local logging to preserve availability while financial endpoints fail closed.
Engineering Trade-offs
Evaluate these foundational architectural trade-offs to justify your rate limiter deployment and data store selection during an interview.
Where Should the Rate Limiter Live?
| Placement | Pros | Cons | When to Use |
|---|---|---|---|
| API Gateway (Centralized) ⭐ | Provides a single centralized enforcement point with uniform policy management and easy rule administration | Can become a performance bottleneck and add latency to every incoming request | Public external-facing APIs requiring coarse-grained rate limits |
| Application Middleware | Provides access to rich user authentication and business context while supporting fine-grained weighted limits | Must be integrated into every microservice codebase with a risk of inconsistent enforcement across teams | Service-specific business limits based on dynamic payload cost |
| Sidecar Proxy (Envoy / Istio) | Language agnostic, decoupled from application logic, and natively integrated with service mesh infrastructure | Introduces operational service mesh complexity with limited dynamic rule expressiveness | Internal microservice east-west traffic and Kubernetes environments |
| Client-Side Throttling | Eliminates unnecessary network traffic and provides proactive client-side pacing | Easily bypassed by malicious clients and cannot be relied upon for security enforcement | Advisory companion SDKs working alongside server-side rate limiters |
| Network Layer (iptables, WAF) | Delivers extremely fast kernel-level packet inspection to absorb volumetric attacks | Enforces coarse IP-only filtering and cannot inspect authenticated user tokens | DDoS attack mitigation and volumetric IP blocking |
Best Practice: Implement defense in depth by combining network-layer DDoS filtering at the edge, coarse-grained API Gateway limits, and fine-grained application middleware for expensive operations.
Why Redis Specifically? Storage Alternatives Compared
| Solution | Latency | Throughput | Atomicity | Persistence | Distributed |
|---|---|---|---|---|---|
| Redis ⭐ | < 0.5 ms | 1M+ ops/sec | Lua scripts execute atomically | Optional (RDB / AOF) | Redis Cluster |
| Memcached | < 0.5 ms | 1M+ ops/sec | Check-And-Set (CAS) only | None (pure in-memory) | Client-side sharding |
| In-Memory Local Map | < 0.01 ms | Hardware bound | Thread-safe concurrent map | None | Single node only |
| Relational DB (PostgreSQL) | ~5 ms | ~10K ops/sec | ACID transactions | Durable persistence | Primary with read replicas |
Why Redis is the optimal choice: First, Redis Lua scripts execute atomically on each shard, allowing check, increment, and expiration logic to complete in a single round trip without concurrency race conditions. Second, built-in key TTLs automatically expire stale rate limit windows without background cleanup threads. Third, Redis Cluster provides horizontal sharding and replication for high availability. Fourth, transient cache loss is acceptable because rate limiting state is ephemeral and fails open.
When local in-memory storage is preferred: If rate limits only need to be enforced per instance rather than globally, an in-memory concurrent map with sliding cleanup provides sub-millisecond execution with zero network dependencies, as seen in libraries like Google Guava RateLimiter.
Fail-Open vs Fail-Closed: The Critical Availability Decision
Fail-Open (allow requests when rate limiter is unavailable): + Legitimate users are never blocked by rate limiter infrastructure failures + Preserves high availability for the overall platform - During an outage, lack of rate enforcement leaves downstream services exposed - Downstream microservices may become overwhelmed by sudden spikes When to use: General API gateways where user experience takes priority over strict throttling Fail-Closed (reject requests when rate limiter is unavailable): + Guarantees absolute rate enforcement even during backend failures - Infrastructure failure blocks all legitimate users, causing a complete API outage - Violates the principle that rate limiters should not degrade service availability When to use: Financial transaction APIs and security-critical authentication endpoints
Recommendation: Adopt endpoint-tier degradation. Default to fail-open with bounded local in-memory limits for general API routes, and configure fail-closed enforcement for authentication and billing endpoints.
Algorithm Selection Framework
Q: Do you require exact rolling-window enforcement against the authoritative store? YES -> Sliding Window Log (stores every timestamp in a Redis ZSET with O(N) memory) NO -> Proceed to next question Q: Do you need to allow short traffic bursts up to a defined burst capacity? YES -> Token Bucket (burst capacity = bucket size B, with smooth average refill rate R) NO -> Proceed to next question Q: Do you need a smooth rolling window with low O(1) memory per key? YES -> Sliding Window Counter (time-weighted linear interpolation of 2 fixed windows) NO -> Proceed to next question Q: Do you need to strictly smooth egress traffic draining to backend services? YES -> Leaky Bucket (FIFO queue draining at constant rate) NO -> Fixed Window Counter (simplest counter that accepts 2x bursts at boundaries)
Algorithm Trade-Off Summary: Token bucket is the most common industry standard for developer APIs due to burst tolerance. Sliding window counter offers the best balance of memory efficiency and boundary smoothing. Sliding window log is reserved for strict compliance requirements where approximation cannot be tolerated.
Distributed Rate Limiting: Exactness vs Latency Spectrum
Scenario: 100 requests per minute limit across 10 API servers Ideal Model: All 10 servers check a centralized Redis counter on every request, ensuring single-authority accuracy. Production Bottlenecks: - Network latency: 0.5ms to 1ms per synchronous check - High aggregate load: Redis Cluster experiences hot-shard CPU pressure under viral keys - Mitigation: Centrally reserved token leases (prefetch batches of 20 tokens) to reduce Redis throughput by 20x Trade-off Spectrum: Accurate (every request checks Redis) <--------------------> Fast (centrally reserved token leases) Centrally Reserved Token Leasing Trade-Off: Each gateway instance atomically reserves a batch of 20 tokens from the global Redis bucket and consumes them locally in memory. Because tokens are reserved centrally before local consumption, serving requests from a valid lease does not itself over-admit the globally reserved quota. The primary trade-off is that if a gateway node crashes, its unconsumed reserved tokens remain temporarily stranded until lease expiry.
Rate Limit Response Headers: Standard Specifications
HTTP/1.1 200 OK
RateLimit-Limit: 100 # Maximum allowed requests within window
RateLimit-Remaining: 42 # Remaining allowed requests in current window
RateLimit-Reset: 1710320460 # Unix timestamp when the current window resets
RateLimit-Policy: 100;w=60 # Policy: 100 requests per 60-second window
HTTP/1.1 429 Too Many Requests
Retry-After: 30 # Seconds until the client may retry
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1710320460Including standardized rate limit headers on every HTTP response allows well-behaved API clients to throttle their own request cadence proactively before encountering HTTP 429 rejections.
Review
How helpful was this walkthrough?
Click a star to rate. We actively use this feedback to refine and update our system design content.
Discussion
Share your thoughts, ask questions, or help others.