API Rate Limiting: Implementation Guide with Redis, Token Bucket, and Sliding Window
API Rate Limiting: Implementation Guide with Redis, Token Bucket, and Sliding Window
Rate limiting is the traffic cop of your API. Without it, a single misbehaving client can take down your entire service. With it, you ensure fair usage, protect backend resources, and provide a predictable experience for all users.
This guide covers the four major rate limiting algorithms, shows how to implement each one with Redis, and helps you choose the right strategy for your use case.
Why Rate Limiting Matters
Without rate limiting, your API is vulnerable to:
- Abuse: A single user making 10,000 requests/second can exhaust database connections, memory, and CPU
- Accidental DDoS: A client bug causing retry storms can take down production
- Resource starvation: One heavy API consumer starves legitimate users
- Cost explosion: Cloud costs scale with requests — uncontrolled traffic means uncontrolled bills
- Security: Brute-force attacks on login, password reset, and OTP endpoints are impossible without rate limits
With rate limiting:
- APIs stay responsive under load
- Fair usage is enforced (freemium vs paid tiers)
- Security is improved (brute-force protection)
- Costs are predictable
The Four Algorithms
1. Fixed Window Counter
How it works: Divide time into fixed windows (e.g., 1-minute blocks). Count requests per window. Reset the counter at the window boundary.
Window: [00:00 - 00:59] → max 100 requests
Window: [01:00 - 01:59] → max 100 requests
Implementation (Redis):
import redis
import time
r = redis.Redis()
def fixed_window(key, limit=100, window=60):
now = int(time.time())
window_key = f"rate:{key}:{now // window}"
pipe = r.pipeline()
pipe.incr(window_key)
pipe.expire(window_key, window)
count, _ = pipe.execute()
if count > limit:
return False # Rate limited
return True # Allowed
Pros: Simple, memory-efficient. Cons: Burst at boundaries — a client can make 100 requests at 00:59 and another 100 at 01:00, effectively sending 200 requests in one second.
2. Sliding Window Log
How it works: Store timestamps of every request. When a new request arrives, remove timestamps older than the window and count the remaining ones.
Implementation (Redis Sorted Set):
def sliding_window_log(key, limit=100, window=60):
now = time.time()
window_start = now - window
pipe = r.pipeline()
# Remove old entries
pipe.zremrangebyscore(key, 0, window_start)
# Count current entries
pipe.zcard(key)
# Add the current request
pipe.zadd(key, {str(now): now})
# Set expiration for cleanup
pipe.expire(key, window)
_, count, _, _ = pipe.execute()
if count >= limit:
return False
return True
Pros: Precise — no boundary bursts. Cons: Memory-heavy — stores every request timestamp.
3. Sliding Window Counter (Recommended)
How it works: Combines fixed window with sliding estimation. Uses the current window's count and the previous window's count, weighted by how far into the current window we are.
def sliding_window_counter(key, limit=100, window=60):
now = time.time()
current_window = int(now // window)
prev_window = current_window - 1
current_key = f"rate:{key}:{current_window}"
prev_key = f"rate:{key}:{prev_window}"
pipe = r.pipeline()
pipe.get(current_key)
pipe.get(prev_key)
pipe.incr(current_key)
pipe.expire(current_key, window * 2)
curr_count, prev_count, _, _ = pipe.execute()
curr_count = int(curr_count or 0)
prev_count = int(prev_count or 0)
# Weighted estimate
elapsed = now - (current_window * window)
weight = 1 - (elapsed / window)
estimated = curr_count + (prev_count * weight)
if estimated > limit:
return False
return True
Pros: Smooth, memory-efficient, no boundary bursts. Cons: Slightly approximate (within ~5% of actual).
4. Token Bucket (Recommended for APIs)
How it works: Tokens are added to a bucket at a fixed rate (e.g., 10 tokens/second). Each request consumes one token. If the bucket is empty, the request is denied. The bucket has a maximum capacity — allowing short bursts.
Capacity: 100 tokens
Refill rate: 10 tokens/second
→ Allows a burst of 100 requests instantly
→ Then sustained 10 req/s
Implementation (Redis + Lua for atomicity):
-- token_bucket.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
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 = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + (elapsed * refill_rate))
local allowed = tokens >= requested
if allowed then
tokens = tokens - requested
end
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, math.ceil(capacity / refill_rate) * 2)
if allowed then
return 1
else
return 0
end
Node.js implementation:
const redis = require('redis');
const fs = require('fs');
const path = require('path');
const client = redis.createClient({ url: 'redis://localhost:6379' });
await client.connect();
// Load Lua script
const luaScript = fs.readFileSync(path.join(__dirname, 'token_bucket.lua'), 'utf8');
const tokenBucket = client.defineCommand('tokenBucket', {
number_of_keys: 1,
lua: luaScript,
});
async function rateLimit(userId, options = {}) {
const {
capacity = 100,
refillRate = 10, // tokens per second
requested = 1, // tokens per request
} = options;
const now = Date.now() / 1000;
const key = `ratelimit:${userId}`;
const allowed = await client.tokenBucket(key, capacity, refillRate, now, requested);
return allowed === 1;
}
// Express middleware
app.use('/api', async (req, res, next) => {
const userId = req.user?.id || req.ip;
const allowed = await rateLimit(userId, { capacity: 100, refillRate: 10 });
if (!allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
});
Pros: Allows bursts (realistic for web traffic), smooth, memory-efficient. Cons: Slightly more complex implementation.
Choosing the Right Algorithm
| Algorithm | Best For | Burst Tolerance | Memory | Accuracy | |-----------|----------|-----------------|--------|----------| | Fixed Window | Simple APIs, low traffic | Poor at boundaries | Low | Medium | | Sliding Window Log | Precise control, low RPS | Good | High | Exact | | Sliding Window Counter | Most APIs | Good | Low | ~95% | | Token Bucket | APIs with bursty traffic | Excellent | Low | Smooth |
Recommendation for most APIs: Token Bucket or Sliding Window Counter.
Distributed Rate Limiting
For multi-server deployments, rate limiting must be coordinated. Redis is the standard choice because it's fast, atomic, and widely supported.
Why Atomicity Matters
This code has a race condition:
# WRONG — race condition
count = r.get(key) or 0
if count >= limit:
return False
r.incr(key)
return True
Two requests can read count = 99 simultaneously, both pass the check, and both increment — allowing 101 requests when only 100 should be permitted.
Atomic Solutions
Option 1: Redis Lua Script (recommended) The Lua scripts above run atomically — no race conditions.
Option 2: Redis Transactions
pipe = r.pipeline()
pipe.multi()
pipe.incr(key)
pipe.expire(key, window)
results = pipe.execute()
count = results[0]
Option 3: Sorted Set with Lua
# Atomic sliding window using Lua
LUA_SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
end
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
"""
Rate Limit Headers
Communicate rate limits to clients via HTTP headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1721419200
Retry-After: 30
Express middleware with headers:
function rateLimitMiddleware(options) {
return async (req, res, next) => {
const userId = req.user?.id || req.ip;
const allowed = await checkRateLimit(userId, options);
res.setHeader('X-RateLimit-Limit', options.capacity);
res.setHeader('X-RateLimit-Remaining', allowed.remaining);
res.setHeader('X-RateLimit-Reset', allowed.resetAt);
if (!allowed.ok) {
res.setHeader('Retry-After', allowed.retryAfter);
return res.status(429).json({
error: 'Too Many Requests',
retry_after: allowed.retryAfter,
});
}
next();
};
}
Tiered Rate Limiting
Different users get different limits:
function getLimitsForUser(user) {
if (!user) return { capacity: 20, refillRate: 2 }; // Anonymous
if (user.tier === 'free') return { capacity: 100, refillRate: 10 }; // Free
if (user.tier === 'pro') return { capacity: 1000, refillRate: 50 }; // Pro
if (user.tier === 'enterprise') return { capacity: 10000, refillRate: 100 }; // Enterprise
return { capacity: 100, refillRate: 10 };
}
app.use('/api', async (req, res, next) => {
const limits = getLimitsForUser(req.user);
// Apply rate limit with limits
});
Per-Endpoint Rate Limiting
Different endpoints need different limits:
// Login: strict (prevent brute force)
app.post('/login', rateLimitMiddleware({ capacity: 5, refillRate: 0.1 }), loginHandler);
// Search: moderate
app.get('/search', rateLimitMiddleware({ capacity: 30, refillRate: 5 }), searchHandler);
// General API: standard
app.use('/api', rateLimitMiddleware({ capacity: 100, refillRate: 10 }));
// Upload: lower
app.post('/upload', rateLimitMiddleware({ capacity: 10, refillRate: 1 }), uploadHandler);
Monitoring and Observability
Track rate limiting metrics:
# Prometheus metrics
from prometheus_client import Counter, Histogram
rate_limited_total = Counter('api_rate_limited_total', 'Requests rate limited', ['endpoint', 'user_tier'])
rate_limit_check_duration = Histogram('api_rate_limit_check_duration_seconds', 'Rate limit check latency')
@rate_limit_check_duration.time()
def check_rate_limit(user_id, endpoint):
allowed = token_bucket_check(user_id, endpoint)
if not allowed:
rate_limited_total.labels(endpoint=endpoint, user_tier=user.tier).inc()
return allowed
Key metrics to watch:
- Rate-limited request rate: How many requests are being blocked?
- Rate limit check latency: Should be <5ms with Redis
- Redis memory usage: Sorted sets can grow if not cleaned up
- 429 response rate per endpoint: Unusual spikes indicate abuse or bugs
Common Mistakes
- Using client IP as the only key — users behind corporate NAT share an IP. Use authenticated user ID when available.
- Not handling Redis failures — if Redis goes down, decide: fail open (allow traffic) or fail closed (deny all). Most APIs fail open.
- Not cleaning up old keys — always set TTL/expiration on rate limit keys.
- Rate limiting on the wrong layer — do it at the application/API gateway layer, not at the load balancer (unless using a specialized rate limiting proxy).
- Ignoring 429 responses — clients should implement exponential backoff with jitter. Document your rate limit headers so they can.
Conclusion
Rate limiting is infrastructure, not an afterthought. For most APIs:
- Use Token Bucket — it handles bursts gracefully
- Use Redis with Lua scripts — atomic, fast, distributed
- Communicate via headers —
X-RateLimit-*andRetry-After - Tier by user type — free vs pro vs enterprise
- Monitor everything — blocked requests tell you about abuse and bugs
A well-implemented rate limiter protects your service, your users, and your cloud bill.