Why Rate Limiting Exists
A public API endpoint without rate limiting is a shared resource with no admission control. A single misbehaving client, whether a bug, a bot, or an attacker, can consume the capacity that was provisioned for thousands of users. Rate limiting is the mechanism by which a system enforces fairness: each client gets a bounded share of capacity, regardless of how aggressively they request.
The goals are distinct but often conflated. Availability protection keeps a single client from taking down the service for everyone else. Security prevents brute force and credential stuffing. Cost control bounds the bill when a compute-heavy endpoint is called at scale. Fairness ensures that a paying customer's experience is not degraded by a free-tier user consuming resources without bound.
What happens without rate limiting
Without rate limiting
An attacker tries 100,000 password combinations against /auth/login. Each attempt is a valid HTTP request. Success rate: 0.1% = 100 accounts compromised.
With rate limiting
5 login attempts per IP per minute. After 5 failures: 429. Attack requires 20,000 IPs to maintain speed, making it economically unviable.
Without rate limiting
A bug in a client retries a failing endpoint in a tight loop. 50 clients x 1000 req/sec = 50,000 req/sec hitting your API. Database falls over.
With rate limiting
100 requests per minute per API key. Runaway client gets 429 after 100 requests. Database load stays bounded.
Without rate limiting
A competitor scrapes your product catalog at 500 req/sec, exporting your pricing data to a spreadsheet. Your CDN bill quadruples.
With rate limiting
10 requests per second per IP on catalog endpoints. Scraper throttled to 1/50th of original speed, making bulk extraction impractical.
Without rate limiting
Your /reports/generate endpoint triggers a 30-second database query. A single user hammers it 10 times concurrently. Database CPU at 100%, all other queries slow.
With rate limiting
2 concurrent requests per user on /reports. Excess requests queued or rejected immediately. Other users unaffected.
Dimensions of rate limiting
By identity
Limit per API key, per user ID, per session token. Authenticated clients get individual quotas. One bad client cannot affect others.
By IP address
Limit per source IP. Effective against unauthenticated endpoints (login, registration). Problematic for shared NATs (corporate offices, mobile carriers).
By endpoint
Different limits for different routes. /reports (expensive) gets 2/min. /ping (cheap) gets 1000/min. Match the limit to the cost of the operation.
By tier
Free tier: 100 requests/day. Pro tier: 10,000/day. Enterprise: unlimited. Rate limits become a product feature and a monetization lever.
By resource
Limit by tokens spent, not requests counted. An AI API charges 1 token for a short completion and 1000 for a long one. Token buckets match resource consumption.
By time granularity
Per-second limits prevent bursts. Per-day limits allow flexible usage patterns. Layering both (10/sec AND 10,000/day) provides burst protection and quota enforcement.
Rate Limiting Algorithms
Rate limiting algorithms differ in how they track usage over time and how they handle bursts. The choice affects memory usage, accuracy at window boundaries, and whether you allow short bursts above the average rate. The three most important to understand are token bucket, fixed window, and sliding window.
Bucket holds 8 tokens. Refills at 1/sec. Each request costs 1 token. Burst up to capacity, then limited to refill rate.
Refill rate: 1 token/sec, burst capacity: 8
Token bucket
The token bucket holds a maximum of N tokens. Tokens are added at a fixed rate (R per second). Each request consumes one token. If the bucket has tokens, the request is allowed. If empty, it is rejected. The bucket accumulates tokens while traffic is low, enabling bursts up to capacity when traffic spikes.
This is the algorithm behind most production rate limiters because burst handling maps to real-world usage: a user typing quickly generates short bursts of API calls, while a background job makes steady calls over time. Both are valid. Token bucket handles both correctly with one parameter set.
Token bucket in Redis (Lua script)
-- Called atomically via EVAL
local key = KEYS[1]
local capacity = tonumber(ARGV[1]) -- max tokens
local refill_rate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3]) -- current Unix timestamp (ms)
local cost = tonumber(ARGV[4]) -- tokens this request costs
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 based on elapsed time
local elapsed = (now - last_refill) / 1000
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens >= cost then
tokens = tokens - cost
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, 3600)
return 1 -- allowed
else
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, 3600)
return 0 -- rejected
endFixed window
Fixed window increments a counter for the current time window. The counter resets when the window expires. Implementation is a single Redis INCR with an expiry: trivially simple and extremely fast.
The weakness is the boundary burst. With a limit of 100/minute, a client can send 100 requests in the last second of minute 1 and 100 in the first second of minute 2, 200 requests in 2 seconds without violating the limit. If your system cannot handle a 2x burst, fixed window is insufficient.
Fixed window in Redis
def is_allowed(user_id: str, limit: int, window_sec: int) -> bool:
now = int(time.time())
window_key = now // window_sec # floor to window boundary
key = f"rl:{user_id}:{window_key}"
count = redis.incr(key)
if count == 1:
redis.expire(key, window_sec * 2) # safety: 2x TTL
return count <= limitSliding window
Sliding window eliminates the boundary burst by tracking the exact time of each request within the window. On each request, timestamps older than (now - window_size) are removed. If the remaining count is below the limit, the request is allowed and its timestamp recorded.
The sliding window counter approximation is more practical at scale: instead of storing every timestamp, it combines the current window's counter with the previous window's counter, weighted by how much of the previous window overlaps with the current sliding window. This trades marginal accuracy for O(1) memory.
Algorithm comparison
| Algorithm | Burst | Memory | Accuracy | Complexity | Best for |
|---|---|---|---|---|---|
| Token Bucket | Yes, up to capacity | O(1) per client | Exact | Low | APIs where bursts are acceptable: upload endpoints, webhook delivery |
| Leaky Bucket | No, constant output rate | O(1) per client | Exact | Low | Smooth traffic to downstream: protecting a database, payment processor |
| Fixed Window | Yes, at window boundary | O(1) per client | Approximate | Very low | Simple quota enforcement: API keys with daily/hourly limits |
| Sliding Window Log | Controlled | O(requests in window) per client | Exact | Medium | Precise rate limiting where boundary bursts are unacceptable |
| Sliding Window Counter | Controlled | O(1) per client | Approximate | Low | Best of both: accurate enough, memory-efficient |
Implementation: Distributed Rate Limiting
A single-process rate limiter is trivial. The challenge in production is distributed rate limiting: multiple application instances must share the same counter so that a client cannot bypass the limit by routing requests across different servers. Redis is the standard solution, a single Redis cluster holds all rate limit state, and application instances check against it on every request.
The critical requirement is atomicity. An INCR followed by an expiry check is not atomic: two simultaneous requests can both read "0" and both allow themselves, counting one request but allowing two. Redis Lua scripts run atomically (the server executes the entire script before processing any other command), making them the correct implementation primitive for rate limiters.
Application middleware pattern (FastAPI)
import time
from fastapi import Request, HTTPException
from redis.asyncio import Redis
redis = Redis(host="localhost", decode_responses=True)
RATE_LIMITS = {
"free": (100, 60), # 100 req / 60 sec
"pro": (1000, 60),
"enterprise": (None, None), # unlimited
}
async def rate_limit_middleware(request: Request, call_next):
user = request.state.user # set by auth middleware
tier = user.subscription_tier # "free" | "pro" | "enterprise"
limit, window = RATE_LIMITS[tier]
if limit is None: # enterprise: skip check
return await call_next(request)
key = f"rl:{user.id}:{int(time.time()) // window}"
count = await redis.incr(key)
if count == 1:
await redis.expire(key, window * 2)
remaining = max(0, limit - count)
reset_at = (int(time.time()) // window + 1) * window
if count > limit:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded",
headers={
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(reset_at),
"Retry-After": str(reset_at - int(time.time())),
},
)
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(limit)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(reset_at)
return response429 response best practices
Always include Retry-After
Clients that receive a 429 without Retry-After have no choice but to retry immediately, creating a storm. Retry-After tells the client exactly how long to wait. Most HTTP clients and SDK retry logic honors it.
Return a structured error body
The 429 body should be machine-readable: { "error": "rate_limit_exceeded", "limit": 100, "reset_at": 1735689600 }. Clients can log it, surface it to users, or adjust retry strategy based on the reset time.
Never silently drop requests
A 429 is informative. Silently dropping the request (connection timeout, no response) is not. The client does not know whether to retry or whether the request was processed.
Return remaining quota on all 2xx responses
Include X-RateLimit-Remaining on every successful response, not just when the limit is near. Well-behaved clients use this to self-throttle before they hit the limit.
Use exponential backoff with jitter on the client
When a client hits a 429, retrying after exactly Retry-After seconds means all rate-limited clients retry simultaneously. Add jitter: wait Retry-After + random(0, 1) seconds to spread the retry load.
Standard response headers
| Header | Example | Meaning |
|---|---|---|
| X-RateLimit-Limit | 100 | Maximum requests allowed in the current window |
| X-RateLimit-Remaining | 43 | Requests remaining in the current window |
| X-RateLimit-Reset | 1735689600 | Unix timestamp when the window resets |
| Retry-After | 47 | Seconds until the client can retry (on 429 responses) |
| X-RateLimit-Policy | 100;w=60 | Machine-readable policy (IETF draft standard) |
Where to enforce rate limits
API Gateway / Reverse proxy
Nginx, Kong, Envoy, AWS API Gateway
Pro: Applied before request reaches application code. Protects all downstream services uniformly. No code change in application.
Con: Coarse-grained: all routes share the same limit unless gateway config supports per-route rules. Less context (no user tier).
Use when: Global protection against unauthenticated abuse, IP-based limiting, and DDoS mitigation.
Application middleware
FastAPI middleware, Django middleware, Express middleware
Pro: Full request context available: user ID, subscription tier, endpoint cost. Per-user and per-tier limits are straightforward.
Con: Applied after network I/O. Application still handles the incoming request (a cost). Must share rate limit state via Redis in multi-instance deployments.
Use when: Authenticated API rate limiting, tier-based quotas, per-endpoint limits.
CDN edge
Cloudflare Rate Limiting, Fastly, Akamai
Pro: Blocking happens at the network edge, before traffic reaches your infrastructure. Protects against volumetric attacks at global scale.
Con: Limited to IP or cookie-based identification. Cannot make application-level decisions (user tier, endpoint cost).
Use when: DDoS mitigation, scraping protection, unauthenticated endpoints.