Every web application faces a fundamental challenge: how do you let legitimate users interact freely while preventing abuse? Without rate limiting, your endpoints are open to brute force attacks, denial-of-service flooding, scraping bots, and resource exhaustion that drives up infrastructure costs.
Rate limiting is the gatekeeper. Here's how to build one that actually works.
Common approaches
1. Fixed window counter
The simplest approach. Divide time into fixed intervals (e.g., every calendar minute) and count requests in each. It's easy to implement, but it has a critical flaw: the boundary exploit.
2. Sliding log (precise but expensive)
Store the timestamp of every request. On each new request, count how many timestamps fall within the last N seconds. Perfectly accurate, but storing every timestamp is memory-intensive at scale.
3. Sliding window (best of both worlds)
Uses a sorted set to track request timestamps, continuously sliding the window forward. Old entries are pruned on every check. No boundary exploits, and memory stays bounded. This is the approach worth building.
How the sliding window works
Instead of resetting a counter at fixed intervals, maintain a sorted set where each member is a unique request ID and its score is the Unix timestamp. On every incoming request:
-
Prune — remove all entries older than
now - window_size - Count — count remaining entries
- Decide — if count ≥ limit, deny; otherwise add the new request and allow
Implementation: Redis + Lua
Redis sorted sets are ideal for this pattern. A Lua script ensures atomicity — no race conditions when multiple requests arrive simultaneously. Without atomicity, two concurrent requests could both see count=59 and both be allowed past a limit of 60.
-- Step 1: Prune expired entries from the main window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- Step 2: Count remaining requests
local current = redis.call('ZCARD', key)
if current >= limit then
return {0, current, limit} -- DENIED
end
-- Step 3: Check burst window
redis.call('ZREMRANGEBYSCORE', burst_key, 0, now - burst_window)
local burst_current = redis.call('ZCARD', burst_key)
if burst_current >= burst_limit then
return {-1, burst_current, burst_limit} -- DENIED: burst
end
-- Step 4: Record the request in both windows
local member = tostring(now) .. ':' .. tostring(math.random(1000000))
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window + 1)
redis.call('ZADD', burst_key, now, member)
redis.call('EXPIRE', burst_key, burst_window + 1)
return {1, current + 1, limit} -- ALLOWED
Why a random suffix on members? Sorted sets require unique members. Two requests arriving at the exact same millisecond would overwrite each other without it, causing undercounting.
Dual-layer protection
A single window limit has a blind spot: a client could exhaust all 60 requests in the first 2 seconds and go silent for 58. The average rate looks fine, but the spike hammers your server. The solution is two checks that must both pass.
| Layer | Window | Limit | Prevents |
|---|---|---|---|
| Window | 60 seconds | 60 requests | Sustained abuse across time boundaries |
| Burst | 5 seconds | 10 requests | Rapid-fire spikes (bots hammering in 1s) |
FastAPI middleware integration
The rate limiter works best as middleware — intercepting every request before it reaches application logic.
@app.middleware('http')
async def rateLimitMiddleware(request, call_next):
# Skip bypass paths (health checks, login)
if request.url.path in bypass_paths:
return await call_next(request)
# Key by user (authenticated) or IP (anonymous)
user = get_authenticated_user(request)
rate_key = f"ratelimit:user:{user}" if user else f"ratelimit:ip:{ip}"
result = await redis.eval(LUA_SCRIPT, keys=[rate_key, burst_key], args=[...])
if result[0] == 0:
return JSONResponse({"error": "Rate limit exceeded."},
status_code=429, headers={"Retry-After": "60"})
if result[0] == -1:
return JSONResponse({"error": "Too many requests. Slow down."},
status_code=429, headers={"Retry-After": "5"})
response = await call_next(request)
response.headers["X-RateLimit-Remaining"] = str(limit - result[1])
return response
| Decision | Rationale |
|---|---|
| Per-user key (authenticated) | Prevents one user from affecting others |
| Per-IP fallback (unauthenticated) | Protects public endpoints from anonymous abuse |
| Fail-open on Redis errors | Avoids blocking legitimate traffic if Redis goes down |
HTTP 429 + Retry-After
|
Standard; well-handled by clients and load balancers |
X-RateLimit-* headers |
Gives clients visibility into their remaining quota |
Testing
A quick smoke test to verify your burst limit fires correctly:
# Send 15 rapid requests — expect 429 after the 10th
for i in $(seq 1 15); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Cookie: session=YOUR_COOKIE" https://your-app/api/me)
echo "Request $i: HTTP $STATUS"
done
# Inspect the sorted set directly
redis-cli ZCARD ratelimit:user:alice@example.com
redis-cli ZRANGE ratelimit:user:alice@example.com 0 -1 WITHSCORES
Summary
Fixed window
Simple but vulnerable to boundary exploits. Low memory, approximate accuracy.
Sliding log
Exact accuracy, no boundary issues. High memory cost at scale.
Sliding window ✓
Exact accuracy, no boundary exploits, moderate memory. The right choice.
Rate limiting isn't just about counting requests — it's about counting them in the right window.



Top comments (0)