Redis rate limiting
Learn to implement rate limiting with Redis to control API or service request rates efficiently.
Focus: implement Redis rate limiting
Your API is under siege — not from a DDoS attack, but from a single misbehaving client hammering your endpoints 1,000 times per second. Without rate limiting, that one user can degrade performance for everyone else, spike your infrastructure costs, or even crash your service. Redis, with its in-memory speed and atomic increment operations, is the perfect tool to enforce fair usage policies with minimal latency overhead.
The problem this lesson solves
Rate limiting is the practice of controlling how many requests a client can make within a given time window. Common challenges include:
- Resource exhaustion — one user can starve others of compute, memory, or database connections.
- Cost overruns — cloud bills scale with usage; a single runaway client can double your monthly spend.
- Abuse & scraping — automated scripts can steal data or overwhelm your API.
- Fairness — without limits, no guarantees exist for other consumers.
Redis solves these problems because it's fast, atomic, and supports time-based data structures like TTL (time-to-live) and sorted sets. You can implement rate limiting with sub-millisecond latency, without external dependencies.
Core concept / mental model
Think of rate limiting like a turnstile at a concert:
- The turnstile only lets 100 people through every minute.
- Each attendee (client IP or user ID) has their own turnstile counter.
- Once the counter hits the limit, the turnstile resets after exactly one minute.
- Attach a timer to each counter:
EXPIRE key 60.
Key Redis features used:
- INCR — atomic increment for counters.
- EXPIRE — auto-cleanup so memory doesn't leak.
- TTL — check remaining time for windows.
- Sorted sets — sliding windows for more precise control.
- Lua scripting — combine multiple operations atomically.
How it works step by step
There are three common algorithms. Here's the simplest: fixed window.
Fixed window algorithm
- A client makes a request.
- Redis runs
INCR rate_limit:{client_id}:{minute}. - If the result is 1, set
EXPIRE rate_limit:{client_id}:{minute} 60. - Compare the current counter value against your limit (e.g., 100).
- If exceeded, reject the request. Otherwise, allow it.
import redis
import time
r = redis.Redis(host='localhost', port=6379, db=0)
def fixed_window_rate_limit(client_id: str, max_requests: int = 100, window_seconds: int = 60) -> bool:
# Use minute-based window for simplicity
current_window = int(time.time() / window_seconds)
key = f"rate_limit:{client_id}:{current_window}"
# Increment the counter atomically
current_count = r.incr(key)
# Set expiry on first request
if current_count == 1:
r.expire(key, window_seconds)
return current_count <= max_requests
# Usage
if fixed_window_rate_limit("user_123"):
print("Request allowed")
else:
print("Rate limit exceeded — try again later")
💡 Pro tip: Use
INCRinstead ofGET + SETto avoid race conditions. Redis commands execute atomically.
Sliding window with sorted sets
A more precise approach uses a sorted set with timestamps as scores.
- Each request adds a member
{timestamp}to a sorted set keyed by client. - Remove entries older than the window with
ZREMRANGEBYSCORE. - Count remaining entries with
ZCARD. - If count ≤ limit, allow. Otherwise, reject.
import time
def sliding_window_rate_limit(client_id: str, max_requests: int = 100, window_seconds: int = 60) -> bool:
key = f"rate_limit:sliding:{client_id}"
now = time.time()
window_start = now - window_seconds
# Remove old entries
r.zremrangebyscore(key, 0, window_start)
# Count current entries
current_count = r.zcard(key)
if current_count >= max_requests:
return False
# Add this request
r.zadd(key, {str(now): now})
r.expire(key, window_seconds)
return True
⚠️ Caveat: Sorted sets use more memory than simple counters. For high-traffic endpoints, fixed windows are more memory-efficient.
Hands-on walkthrough
Setup
# Start Redis locally via Docker
docker run --name redis-ratelimit -p 6379:6379 -d redis:7-alpine
Python implementation with Lua scripting
For atomicity in single-shot operations, use Lua scripts:
import redis
import time
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
# Lua script for fixed window (prevents race conditions in multi-step logic)
SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0
else
return 1
end
"""
script_hash = r.script_load(SCRIPT)
def rate_limit_lua(client_id: str, limit: int = 100, window: int = 60) -> bool:
key = f"rate_limit:{client_id}:{int(time.time()/window)}"
result = r.evalsha(script_hash, 1, key, str(limit), str(window))
return bool(result)
# Simulate 5 rapid requests
for i in range(5):
allowed = rate_limit_lua("test_user", limit=3, window=10)
print(f"Request {i+1}: {'Allowed' if allowed else 'Blocked'}")
time.sleep(0.5)
Expected output:
Request 1: Allowed
Request 2: Allowed
Request 3: Allowed
Request 4: Blocked
Request 5: Blocked
Compare options / when to choose what
| Algorithm | Pros | Cons | Best for |
|---|---|---|---|
| Fixed window | Simple, memory-efficient (< 1 KB per client) | Boundary bursts — 100 requests at 59s, then 100 at 60s | Low traffic / public APIs |
| Sliding window (sorted set) | Smooth limits, no boundary bursts | More memory (each request logged) | Premium APIs, per-second policies |
| Token bucket (in-process) | Extremely low overhead, no Redis for each request | Not distributed, harder to sync across nodes | Single-server rate limiting |
| Lua scripting | Atomic, redcues network round trips | Slightly complex to debug | High-throughput production |
Recommendation: Start with fixed window for simplicity, add Lua scripting for atomic updates, then migrate to sliding windows only if boundary bursts become a real issue.
Troubleshooting & edge cases
Common issues
- Memory leaks — Forgot to set
EXPIREon keys that never expire. Always set TTL on the first increment. - Clock skew — Sliding windows depend on server time; skewed clocks cause inaccurate limits. Use NTP.
- High cardinality — Thousands of unique clients create many keys. Use
EXPIREwith a TTL equal to your window size. - Race conditions — Using
GETthenSETincorrectly can let two requests slip through. UseINCRor Lua.
Edge case: Atomic expiry collision
If incr returns 1 (new key) but expire fails (e.g., key deleted between commands), the key persists forever. Solution: use Lua or SET key 1 EX 60 NX.
# Safer single-command approach
key = f"rate_limit:{client_id}"
current = r.incr(key)
r.expire(key, 60, nx=True) # Only set if not already set
What you learned & what's next
You've implemented two core rate-limiting algorithms with Redis: the simple fixed window and the more precise sliding window using sorted sets. You've seen how to make operations atomic with Lua scripts, avoiding race conditions in production. You now understand the trade-offs between memory usage, accuracy, and simplicity.
Next up: In the next lesson, you'll build on these patterns to implement distributed fair queueing with Redis Streams and consumer groups — managing backpressure across multiple workers.
Key takeaways: - Redis's atomic operations make it ideal for in-memory rate limiting. - Always set TTL on counters to prevent memory bloat. - Lua scripts guarantee atomic execution of multi-step logic. - Choose fixed window for simplicity; sliding window for precision. - Memory overhead grows linearly with unique clients — plan accordingly.
Practice recap
Try extending the Lua script example to implement a sliding window rate limiter. Use ZADD, ZREMRANGEBYSCORE, and ZCARD inside a Lua script that returns whether the request is allowed. Test it with different window sizes and limits to understand memory usage patterns.
Common mistakes
- Forgetting to call EXPIRE on newly created counter keys, causing unbounded memory growth as keys accumulate for every client.
- Using GET + SET instead of INCR, creating race windows where concurrent requests can exceed the limit briefly.
- Setting the TTL to the same value as the window but not renewing it on each request; the counter disappears before the window ends.
- Using sorted sets for every request without pruning old entries, leading to massive sorted sets that slow ZCARD operations.
Variations
- Generic cell rate algorithm (GCRA) — uses a leaky bucket model; good for constant-rate APIs with Redis sorted sets.
- In-memory token bucket with a local cache (e.g.,
functools.lru_cache) for per-worker rate limiting before hitting Redis. - Redis-backed sliding window using a sorted set with timestamps, adjusted for high precision with
ZREMRANGEBYSCOREandZCARD.
Real-world use cases
- GitHub API rate limiting: Restrict unauthenticated requests to 60 per hour per IP using a fixed window with client IP as key.
- SMS OTP delivery: Limit SMS sending to 5 per minute per phone number to prevent abuse and reduce costs.
- Login attempts: Track failed login attempts per username globally; block after 10 failures within 15 minutes using INCR + EXPIRE.
Key takeaways
- Redis rate limiting prevents resource exhaustion by controlling client request rates with minimal latency.
- Fixed window using INCR + EXPIRE is the simplest approach; sliding window using sorted sets offers smoother limits.
- Lua scripting guarantees atomic execution for multi-step rate limiting logic across distributed clients.
- Always set TTL on new keys to avoid memory leaks; prune old entries in sliding windows to keep memory bounded.
- Choose implementation based on traffic patterns: fixed window for low-traffic APIs, sliding window for premium services.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.