Rate Limiting with Redis
API key sk_live_abc sent 4,000 requests in the minute after a cron job misconfigured its polling interval. Your per-key limit was 1,000 per minute. Without a shared counter, three app servers each counted 1,333 requests locally and saw no problem. Redis-backed rate limiting aggregates counts across every instance so the 1,001st request gets a 429 regardless of which server handles it.
Fixed window counter
Simplest approach — count requests per clock-aligned window:
import redis
import time
r = redis.Redis(host="redis.internal")
def fixed_window_limit(key: str, limit: int, window_seconds: int) -> tuple[bool, int]:
window_key = f"rl:{key}:{int(time.time()) // window_seconds}"
pipe = r.pipeline()
pipe.incr(window_key)
pipe.expire(window_key, window_seconds)
count, _ = pipe.execute()
remaining = max(0, limit - count)
return count <= limit, remaining
Problem: A client can send limit requests at 00:00:59 and another limit at 00:01:00 — 2x the intended rate in a two-second burst.
Sliding window log
Track individual request timestamps in a sorted set:
def sliding_window_limit(key: str, limit: int, window_ms: int) -> tuple[bool, int]:
now = time.time() * 1000
redis_key = f"rl:sw:{key}"
pipe = r.pipeline()
# Remove expired entries
pipe.zremrangebyscore(redis_key, 0, now - window_ms)
# Add current request
pipe.zadd(redis_key, {str(now): now})
# Count requests in window
pipe.zcard(redis_key)
# Set TTL for cleanup
pipe.expire(redis_key, int(window_ms / 1000) + 1)
_, _, count, _ = pipe.execute()
remaining = max(0, limit - count)
return count <= limit, remaining
No boundary burst problem. Memory scales with request volume in the window — fine for API limits (hundreds per minute), expensive for millions per second.
Token bucket
Allow bursts while maintaining average rate:
BUCKET_LUA = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now
local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 1)
return {1, math.floor(tokens)}
else
return {0, 0}
end
"""
def token_bucket_limit(key: str, capacity: int, refill_per_second: float) -> tuple[bool, int]:
redis_key = f"rl:tb:{key}"
now = time.time()
allowed, remaining = r.eval(
BUCKET_LUA, 1, redis_key, capacity, refill_per_second, now
)
return bool(allowed), int(remaining)
capacity=100, refill_per_second=10 allows 100-request bursts, then steady 10/second. Lua ensures atomicity.
Middleware integration
from functools import wraps
def rate_limit(limit: int = 100, window: int = 60):
def decorator(fn):
@wraps(fn)
def wrapper(request):
key = f"apikey:{request.api_key}"
allowed, remaining = sliding_window_limit(key, limit, window * 1000)
if not allowed:
return Response(
status=429,
headers={
"Retry-After": str(window),
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": "0",
},
body="Rate limit exceeded",
)
response = fn(request)
response.headers["X-RateLimit-Limit"] = str(limit)
response.headers["X-RateLimit-Remaining"] = str(remaining)
return response
return wrapper
return decorator
Tiered limits
Different limits per plan tier:
TIER_LIMITS = {
"free": {"requests": 100, "window": 3600},
"pro": {"requests": 1000, "window": 3600},
"enterprise": {"requests": 10000, "window": 3600},
}
def check_tier_limit(api_key: str) -> tuple[bool, int]:
tier = get_api_key_tier(api_key)
config = TIER_LIMITS[tier]
return sliding_window_limit(
f"tier:{api_key}",
config["requests"],
config["window"] * 1000,
)
Store tier in the API key metadata. Limits apply per key, not per IP — preventing circumvention by rotating IPs.
Redis Cell module
Redis offers the Cell module (formerly Redis-Cell) for native rate limiting:
CL.THROTTLE user:42 15 30 10 2
# key, max_burst, rate_period, quantity, cost
Returns [limited, total_rate, remaining, retry_in] in one atomic call. Available in Redis Enterprise and some managed providers.
Algorithm selection
| Algorithm | Accuracy | Burst handling | Memory | Complexity |
|---|---|---|---|---|
| Fixed window | Low | Bad (2x at boundary) | Low | Trivial |
| Sliding window log | High | None | Per-request | Moderate |
| Token bucket | Moderate | Controlled bursts | Low | Moderate |
| Sliding window counter | Good | Minimal | Low | Low |
Sliding window counter (hybrid) — approximate sliding window by weighting the previous and current fixed windows. Good balance of accuracy and memory for high-traffic APIs.
Treat production rollout as a measured change: ship with observability, validate rollback, and review metrics 24 hours after deploy — patterns that look obvious in docs fail when skipped under release pressure.
Common production mistakes
Teams get rate limiting sliding window wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
Redis usage for rate limiting sliding window loses data when persistence mode is misunderstood, hot keys saturate single shards, and TTL strategy is applied after memory pressure already triggered evictions.
Debugging and triage workflow
When rate limiting sliding window misbehaves in production, work top-down instead of guessing:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.
Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.
Resources
- Redis rate limiting patterns
- Stripe rate limiter engineering blog
- IETF RateLimit header draft
- redis-cell module
- Cloudflare rate limiting rules
Frequently asked questions
Why use Redis for rate limiting?
Rate limits must be enforced consistently across all application instances. In-memory per-process counters let clients exceed limits by routing requests to different servers. Redis provides atomic operations and sub-millisecond latency for shared counters that every instance reads and writes. It is the standard backend for distributed rate limiting.
Which rate limiting algorithm is most accurate?
Sliding window log tracks individual request timestamps and counts within a rolling window — the most accurate approach. Token bucket allows controlled bursts while maintaining an average rate. Fixed window is simplest but allows up to 2x the limit at window boundaries. For API rate limits where precision matters, use sliding window log.
How do I return rate limit status to API clients?
Include standard headers on every response: X-RateLimit-Limit (max requests), X-RateLimit-Remaining (requests left), X-RateLimit-Reset (window reset timestamp). On 429 responses, add Retry-After with seconds until the client can retry. These headers let clients self-throttle before hitting limits.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →