Implement API Rate Limiting with Redis
Protect APIs from abuse with Redis-backed token bucket and sliding window limiters, including burst handling and distributed coordination
Public APIs attract abuse: scrapers, brute-force attempts, and clients that ignore polite retry behavior. A Redis-backed rate limiter keeps quota state in one place so every node of the API enforces the same limits. This recipe implements the two workhorse algorithms — token bucket and sliding window — with atomic Lua scripts, per-tier configuration, and the response headers clients need to back off correctly. For the security side of throttling (which layer blocks what, how to identify clients), see the Security Best Practices Guide.
When to Use This
- Public APIs that need protection against brute force and scraping
- Different user tiers require different rate limits (free vs paid)
- Multiple API nodes must share rate limit state consistently
- Webhook or callback endpoints that receive unpredictable bursts from external systems
- Skip it when a single-instance, low-traffic API is enough: an in-memory limiter avoids the extra network hop to Redis on every request
Solution
1. Token Bucket Algorithm
// rate-limiter/TokenBucket.ts
import { Redis } from 'ioredis';
class TokenBucketLimiter {
private redis: Redis;
constructor(redis: Redis) {
this.redis = redis;
}
async check(key: string, capacity: number, refillRate: number): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
const bucketKey = `ratelimit:token:${key}`;
const luaScript = `
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local interval = tonumber(ARGV[3])
local time = redis.call('TIME')
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
local tokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now
local elapsed = now - lastRefill
local refill = math.floor(elapsed / interval) * refillRate
tokens = math.min(capacity, tokens + refill)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
redis.call('EXPIRE', KEYS[1], 3600)
return {1, tokens, now + interval}
else
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', lastRefill)
redis.call('EXPIRE', KEYS[1], 3600)
return {0, tokens, lastRefill + interval}
end
`;
const result = await this.redis.eval(luaScript, 1, bucketKey, capacity, refillRate, 1000);
const [allowed, remaining, resetTime] = result as [number, number, number];
return {
allowed: allowed === 1,
remaining,
resetTime,
};
}
}
Reading TIME inside the Lua script uses the Redis server clock, so every app node measures elapsed time against the same source, eliminating clock-skew drift between instances.
2. Sliding Window Counter
// rate-limiter/SlidingWindow.ts
class SlidingWindowLimiter {
private redis: Redis;
constructor(redis: Redis) {
this.redis = redis;
}
async check(key: string, limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
const now = Date.now();
const windowKey = `ratelimit:window:${key}`;
const windowStart = now - windowMs;
const pipeline = this.redis.pipeline();
pipeline.zremrangebyscore(windowKey, 0, windowStart);
pipeline.zcard(windowKey);
pipeline.zadd(windowKey, now, `${now}-${Math.random()}`);
pipeline.pexpire(windowKey, windowMs);
const results = await pipeline.exec();
// exec() returns [err, result] tuples: index 1 holds the ZCARD count
const currentCount = (results?.[1]?.[1] ?? 0) as number;
if (currentCount < limit) {
return {
allowed: true,
remaining: limit - currentCount - 1,
resetTime: now + windowMs,
};
}
// Remove the request we just added
await this.redis.zremrangebyrank(windowKey, -1, -1);
const oldest = await this.redis.zrange(windowKey, 0, 0, 'WITHSCORES');
const resetTime = oldest.length > 0 ? Number(oldest[1]) + windowMs : now + windowMs;
return {
allowed: false,
remaining: 0,
resetTime,
};
}
}
3. Express Middleware with Headers
// middleware/rateLimit.ts
import { Request, Response, NextFunction } from 'express';
function rateLimit(limiter: TokenBucketLimiter, options: { capacity: number; refillRate: number }) {
return async (req: Request, res: Response, next: NextFunction) => {
const key = `${req.ip}:${req.route?.path || req.path}`;
const result = await limiter.check(key, options.capacity, options.refillRate);
res.setHeader('RateLimit-Limit', String(options.capacity));
res.setHeader('RateLimit-Remaining', String(Math.max(0, result.remaining)));
res.setHeader('RateLimit-Reset', String(Math.ceil(result.resetTime / 1000)));
if (!result.allowed) {
res.setHeader('Retry-After', String(Math.ceil((result.resetTime - Date.now()) / 1000)));
res.status(429).json({
error: 'Too Many Requests',
retryAfter: Math.ceil((result.resetTime - Date.now()) / 1000),
});
return;
}
next();
};
}
// Usage
app.use('/api/public', rateLimit(limiter, { capacity: 100, refillRate: 10 }));
app.use('/api/premium', rateLimit(limiter, { capacity: 1000, refillRate: 100 }));
The RateLimit-* header names follow the IETF draft for rate limit fields; the older X-RateLimit-* convention still works but the unprefixed form is what newer clients and gateways parse.
4. Per-User Tier Configuration
// rate-limiter/TierConfig.ts
interface RateLimitConfig {
capacity: number;
refillRate: number;
}
const tierLimits: Record<string, RateLimitConfig> = {
free: { capacity: 100, refillRate: 10 },
pro: { capacity: 1000, refillRate: 100 },
enterprise: { capacity: 10000, refillRate: 1000 },
};
async function getUserTier(userId: string): Promise<string> {
// Fetch from database or cache
return 'pro';
}
// Middleware with tier lookup
async function tieredRateLimit(req: Request, res: Response, next: NextFunction) {
const userId = req.headers['x-user-id'] as string;
const tier = userId ? await getUserTier(userId) : 'free';
const config = tierLimits[tier] || tierLimits.free;
const key = `${tier}:${userId || req.ip}`;
const result = await limiter.check(key, config.capacity, config.refillRate);
if (!result.allowed) {
res.status(429).json({ error: 'Rate limit exceeded', tier });
return;
}
next();
}
Python Variant (redis-py)
The same Lua script runs from Python through register_script, which caches it server-side and sends EVALSHA on subsequent calls. Time still comes from redis.call('TIME'), so every app node measures against the same clock:
# rate_limiter.py (pip install redis)
import redis
TOKEN_BUCKET_LUA = """
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or ARGV[1])
local last = tonumber(redis.call('HGET', KEYS[1], 'last') or '0')
local t = redis.call('TIME')
local now = t[1] + t[2] / 1000000
tokens = math.min(tonumber(ARGV[1]), tokens + (now - last) * tonumber(ARGV[2]))
local allowed = tokens >= 1
if allowed then tokens = tokens - 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'last', now)
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return allowed and 1 or 0
"""
def make_limiter(client: redis.Redis):
script = client.register_script(TOKEN_BUCKET_LUA)
def check(key: str, capacity: int, refill_rate: float) -> bool:
ttl_ms = int(capacity / refill_rate * 2000)
return bool(script(keys=[f"rate:{key}"], args=[capacity, refill_rate, ttl_ms]))
return check
For a batteries-included Python implementation, see Python API Rate Limiting with Token Bucket.
How It Works
- Token bucket allows bursts up to capacity while holding the average rate
- Sliding window gives stricter limits by tracking exact request timestamps
- Redis Lua scripts make the check-and-decrement atomic across distributed nodes
- Response headers tell clients their remaining quota and when the window resets
Choosing an Algorithm
The four common windowing strategies differ in memory cost and strictness:
| Algorithm | Memory per client | Accuracy | Best for |
|---|---|---|---|
| Token bucket | O(1) hash fields | Average rate; allows bursts up to capacity | Most APIs — steady rate with controlled bursts |
| Fixed window counter | O(1), one INCR key | Boundary flaw: up to 2x limit at window edges | Low-precision limits where simplicity wins |
| Sliding window counter | O(1), two counters | Approximate (weighted previous window) | Better accuracy than fixed window at O(1) cost |
| Sliding window log | O(n), one entry per request | Exact count in any rolling window | Compliance APIs that must enforce exact counts |
The sliding window log stores a sorted-set entry per request (ZADD rate:log:{key} <ts> <requestId>, prune with ZREMRANGEBYSCORE, count with ZCARD), so memory grows with traffic; prefer token bucket for high-volume endpoints. Without Redis, the same token bucket can run on PostgreSQL SELECT FOR UPDATE or DynamoDB conditional writes, at the cost of a database round trip per check; CDN-level rules (Cloudflare rate limiting, AWS WAF rate-based rules) can also block abuse before it reaches the server.
Production Considerations
- Use Redis Cluster for high-availability rate limit state; hash tags like
rate:{tenantId}:…keep a client’s keys on the same slot so multi-key Lua operations stay atomic - Implement a circuit breaker around Redis to fail open if the cache goes down
- Log rate limit violations for security monitoring and abuse detection
Common Mistakes
- Rate limiting only by IP, which blocks legitimate users behind NAT: combine IP with API key or user ID when available
- Not handling Redis failures gracefully, causing API outages when the cache is slow or unreachable
- Returning 429 without
Retry-After, leaving clients guessing when to retry - Doing
SET+EXPIREas separate calls; without Lua orMULTIthe check-and-decrement isn’t atomic and concurrent requests overshoot the limit - Forgetting a TTL on rate limit keys, so inactive clients keep keys alive forever and Redis memory grows without bound
Troubleshooting
- Every client gets 429 right after a deploy: check whether the key prefix or hash-tag layout changed: a new prefix restarts counters and makes healthy clients look throttled
- Users behind the same corporate proxy get throttled together: you’re keying on IP; switch to API key or user ID, or raise limits on NAT-heavy endpoints
- p99 latency spikes when Redis is slow: set a command timeout (~50ms) and fail open; inspect slow
EVALSHAcalls withSLOWLOG GET - Rate limit keys never expire: audit with
SCAN+TTL: every mutating command in the script must also setEXPIRE/PEXPIRE - Inconsistent limits across nodes: app-server clocks drift; read
redis.call('TIME')inside the Lua script instead of passingDate.now()from each instance
Further Reading
- RateLimit header fields for HTTP — IETF draft
- Redis Lua scripting with EVAL
- ioredis pipelining
- Token Bucket Rate Limiter pattern
- Express Middleware Patterns for Express-specific middleware composition
- Runnable companion project — the four TypeScript files from this recipe as a ready-to-run Express middleware package
Frequently Asked Questions
Token bucket vs sliding window: which should I use?
Token bucket allows controlled bursts and is cheaper on Redis (one Lua call per request). Sliding window is stricter and better when you must enforce exact counts in a rolling window — think financial APIs with compliance requirements. For most APIs, token bucket with a burst size of 2x the steady-state rate is enough.
What happens when Redis goes down — does the API break?
Only if you fail closed. Wrap Redis calls in a circuit breaker: when Redis is unavailable, fail open (allow the request) and log a warning: failing closed turns a cache outage into a full API outage. For short outages, fall back to an in-memory counter like lru-cache. Set a ~50ms command timeout so a slow Redis can't stall request processing, and alert while the breaker is open.
How do I set per-tier rate limits (free, pro, enterprise)?
Carry the tier in the API key or JWT claims and key the bucket per tier: rate:free:{key}, rate:pro:{key}, rate:enterprise:{key}, each with its own capacity and refill rate. Return the tier in a response header (RateLimit-Tier: pro) so clients can debug their limits. Read tier config from a database or config service so you can change limits without redeploying.
How do I handle rate limits across microservices?
Share one Redis instance (or a dedicated rate limiting service) and let each service call the limiter before processing. For service-to-service calls, give each service pair its own bucket so one downstream can't exhaust another's quota. An API gateway like Kong or Envoy can enforce limits centrally instead. On Redis Cluster, use hash tags so each client's keys land on the same slot.
Can I rate limit by something other than IP?
Yes — and usually you should. Key on API key (rate:apikey:{sha256(apiKey)}), user ID for authenticated traffic, or a tenant namespace for multi-tenant APIs (rate:tenant:{tenantId}:{key}). A common split: rate:ip:{ip} at 10 req/min for anonymous requests and rate:user:{userId} at 100 req/min for authenticated ones. Hash API keys before using them as Redis keys.
What headers should I return on a 429?
At minimum Retry-After (seconds until the client can retry). For normal responses return RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset (Unix timestamp) per the IETF draft, or the legacy X-RateLimit-* equivalents if your clients already parse those. Don't expose internal configuration like bucket size or refill rate.
How do I rate limit WebSocket connections?
Limit by connection ID, not IP. Track active connections per user in a sorted set (ZADD ws:connections <ts> <connectionId>), cap connections per user, and count messages per connection with a token bucket at rate:ws:{connectionId}. Close abusive clients with WebSocket close code 1008 (policy violation) and clean up entries on disconnect with ZREM.
How do I rate limit GraphQL APIs?
Rate limit by query cost, not request count: a single query can cost as much as 100 REST calls. Score fields by complexity, compute the total before execution (the graphql-cost-analysis package does this), and accumulate the cost in Redis with INCRBY rate:gql:{userId} <cost>. Persisted queries let you cache the computed cost. Return RateLimit-Cost headers so clients see what each query spends.
How do I test rate limiting logic?
Unit-test the Lua script directly with redis-cli --eval against mock keys, covering the boundary cases: exactly at the limit, one over, and refill after waiting. For middleware tests, use ioredis-mock or a real Redis in a testcontainers container, fire N rapid requests, and assert that request N+1 returns 429 with a Retry-After header. Verify atomicity under Promise.all concurrency, and run k6 or artillery to check the limiter holds up at expected throughput.
How do I monitor and alert on rate limiting?
Track four numbers: total requests, 429 responses, top throttled clients, and Redis command latency. Alert when 429s pass ~5% of traffic: either the limits are too tight or a client is misbehaving. Watch EVALSHA latency; anything past ~10ms means the script or Redis needs attention. Alert when the circuit breaker opens, because that's when rate limiting silently turns off.
Related Resources
API Rate Limiting
Protect APIs from abuse and ensure fair resource usage with token bucket, sliding window, and leaky bucket rate limiting.
RecipePython API Rate Limiting with Token Bucket
Implement token bucket rate limiting in Flask and FastAPI with Redis support
RecipeRate Limiting for API Security: Block Abuse and Brute Force
Use rate limiting as a security control against brute-force, credential stuffing, scraping, and L7 DDoS. Layered limits, per-endpoint tuning, and 429 handling.
PatternToken Bucket Rate Limiter: Smooth Traffic with Token Buckets
How to implement token bucket rate limiting for API protection — bucket refill, burst handling, per-key quotas, and distributed limits with Redis.
DocAPI Rate Limiting Policy Template
A template for defining API rate limits per consumer tier, including burst allowances, quota periods, and escalation paths.
GuideWebhook Security — Delivery, Verification, and Protection
A practical guide to securing webhooks: HMAC signature verification, replay attack prevention, idempotency, endpoint hardening, and secret rotation.