Rate Limiting
Rate limiting protects your APIs from abuse, runaway clients, and brute-force attacks. Redis is the standard backbone: atomic counters, TTLs, and Lua scripts give you fixed-window, sliding-window, leaky-bucket, and token-bucket limiters in a few lines of code.
Fixed window, sliding, token bucket, Lua
EXAMPLE
// 1) Fixed-window — simplest
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function fixedWindow(key, limit, windowSec) {
const bucket = `${key}:${Math.floor(Date.now() / 1000 / windowSec)}`;
const count = await redis.incr(bucket);
if (count === 1) await redis.expire(bucket, windowSec);
return { allowed: count <= limit, current: count, remaining: Math.max(0, limit - count) };
}
// Usage
const r = await fixedWindow(`rl:ip:${req.ip}`, 100, 60);
if (!r.allowed) return res.status(429).set('Retry-After', '60').end();
// Pros: trivial. Cons: 'edge' bursts — a client can do 100 at second 59 and 100 at second 60.
// 2) Sliding-window log — exact but memory-heavy
async function slidingLog(key, limit, windowMs) {
const now = Date.now();
const minScore = now - windowMs;
const member = `${now}-${Math.random()}`;
const multi = redis.multi();
multi.zremrangebyscore(key, 0, minScore);
multi.zadd(key, now, member);
multi.zcard(key);
multi.pexpire(key, windowMs);
const [, , count] = await multi.exec().then((r) => r.map((x) => x[1]));
return { allowed: count <= limit, current: count };
}
// Stores one entry per request in a sorted set. Best for small-volume per-user limits.
// 3) Sliding-window counter — approximation, lower memory
async function slidingCounter(key, limit, windowSec) {
const now = Math.floor(Date.now() / 1000);
const currentBucket = `${key}:${Math.floor(now / windowSec)}`;
const prevBucket = `${key}:${Math.floor(now / windowSec) - 1}`;
const positionInWindow = (now % windowSec) / windowSec; // 0..1
const multi = redis.multi();
multi.incr(currentBucket);
multi.expire(currentBucket, windowSec * 2);
multi.get(prevBucket);
const [curr, , prev] = await multi.exec().then((r) => r.map((x) => x[1]));
const weighted = Number(prev ?? 0) * (1 - positionInWindow) + Number(curr);
return { allowed: weighted <= limit, current: Math.round(weighted) };
}
// 4) Token bucket — burst-friendly, classic
const TOKEN_BUCKET = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now
local delta = math.max(0, now - ts)
tokens = math.min(capacity, tokens + delta * refill)
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill))
return { allowed and 1 or 0, tokens }
`;
async function tokenBucket(key, capacity, refillPerSec, cost = 1) {
const [allowed, tokens] = await redis.eval(
TOKEN_BUCKET, 1, key,
capacity, refillPerSec, Date.now() / 1000, cost,
);
return { allowed: allowed === 1, tokens };
}
// 5) Leaky bucket — queue-style smoothing
const LEAKY = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local state = redis.call('HMGET', key, 'level', 'ts')
local level = tonumber(state[1]) or 0
local ts = tonumber(state[2]) or now
level = math.max(0, level - (now - ts) * leak)
local allowed = level + 1 <= capacity
if allowed then level = level + 1 end
redis.call('HMSET', key, 'level', level, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / leak))
return { allowed and 1 or 0, level }
`;
// 6) Express middleware example
function rateLimit({ keyer, limit, windowSec }) {
return async (req, res, next) => {
const key = `rl:${keyer(req)}`;
const r = await fixedWindow(key, limit, windowSec);
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', Math.max(0, limit - r.current));
if (!r.allowed) return res.status(429).setHeader('Retry-After', windowSec).end('Too Many Requests');
next();
};
}
app.use(rateLimit({
keyer: (req) => `ip:${req.ip}`,
limit: 100, windowSec: 60,
}));
// 7) Per-user vs per-IP vs per-route
// Layer multiple limits:
// • Per-IP global — 100/min
// • Per-user authed — 1000/min
// • Per-route auth — 5/min on /login
// • Per-tenant — by API key
// Each layer can fail independently; first to deny wins.
// 8) Communicating limits to clients
// Standard headers:
// X-RateLimit-Limit 100
// X-RateLimit-Remaining 87
// X-RateLimit-Reset 1716937800 (epoch seconds)
// Retry-After 30 (seconds OR HTTP-date)
// Use RFC 9110 'Retry-After' for 429 + 503.
// 9) Distributed safety — single Redis vs cluster
// • Use one Redis cluster for global limits
// • If using cluster mode, ensure all keys for a limiter hash to the SAME slot via hash tags
// e.g. rl:{userId}:fixed rl:{userId}:sliding
// • Otherwise multi/EVAL across slots fails with CROSSSLOT
// 10) Local fallback when Redis is down
// • Decide policy: 'fail open' (allow) or 'fail closed' (deny)
// • For abuse-prevention limits → fail open with a local lower limit
// • For paid-tier quotas → fail closed (don't give away free service)
function safeLimit(fn, fallbackAllow) {
return async (...args) => {
try { return await fn(...args); }
catch (e) {
log.warn('rate-limit redis down', e);
return { allowed: fallbackAllow, current: 0 };
}
};
}
// 11) Libraries to consider
// • rate-limiter-flexible (Node) — production-ready, multiple backends
// • express-rate-limit + rate-limit-redis (Node)
// • django-ratelimit / django-redis (Python)
// • limits (Python, FastAPI/Starlette)
// • Sidekiq Throttle (Ruby)
// • Bucket4j (Java)
// • Most production stacks ship behind an API gateway (Kong, Envoy) that handles rate limiting
// 12) Common bugs
// • TTL not set on counter key → memory leak; always EXPIRE
// • Atomicity broken in multi-step logic → use Lua script
// • Using ip-based limit behind a proxy — read trust-proxy headers (X-Forwarded-For) correctly
// • Limit too low for legit batch endpoints → endpoints need different limits
// • 429 response without Retry-After → clients spam retries
// • Counters reset on Redis restart — accept the drift or use AOF persistence
// • Per-key memory unbounded — set sensible TTLs + monitor key counts
// • Banning IP forever on rate-limit hit → use temporary block, allow recovery
// • Sliding-log on a million users — too much memory; switch to sliding-counter or token bucket
Why it matters
Redis is the natural place for rate limiting: atomic counters with TTLs for fixed/sliding windows, Lua scripts for token-bucket and leaky-bucket algorithms, and stable headers (X-RateLimit-* + Retry-After) so clients can back off. Layer per-IP + per-user + per-route limits, decide your fail-open/fail-closed policy, and always set a TTL so memory stays bounded.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# Fixed window: 100 req / min per IP key = "rl:" + ip + ":" + minute INCR $key EXPIRE $key 60 # Block if value > 100Try it Yourself »
Discussion
Loading…