Rate Limiting Algorithms
Fixed window, sliding window, token bucket and leaky bucket — what each one actually does at the boundary.
Rate limiting looks like one feature and is four algorithms with materially different behaviour. The differences show up exactly where it matters: at the edge of the window.
Fixed window
Count requests per calendar minute; reset at the boundary. One counter, trivially cheap. The flaw is the boundary burst: a client can send the full limit at 11:59:59 and again at 12:00:00, achieving double the intended rate for a moment.
Sliding window log
Store a timestamp per request and count those inside the window. Exact, and it costs memory proportional to the limit — fine for a limit of 100, expensive for 100,000.
Sliding window counter
Weight the previous window's count by how much of it remains. Approximate, cheap, and close enough for almost everything. This is the usual production choice.
Token bucket
Tokens refill at a steady rate up to a maximum; each request spends one. It permits a burst up to the bucket size, then settles to the refill rate. This matches real client behaviour — page loads arrive in bursts — and it is the friendliest algorithm for legitimate users.
Leaky bucket
Requests queue and drain at a fixed rate, smoothing traffic completely. Good for protecting a downstream system that cannot absorb spikes at all.
Choosing
Token bucket for general API traffic; sliding window counter where you need a strict rate; fixed window only where simplicity beats precision. Cost-weight expensive endpoints — a report generation should spend more tokens than a health check.
Tell the client
Return RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and, on a 429, Retry-After. A client that cannot see the limit retries blindly and makes the problem worse.
Example
# The fixed-window boundary burst, concretely
# Limit: 100 requests per minute
11:59:00 ─────────────────────────── 12:00:00 ─────────────────────────── 12:01:00
100 reqs │ 100 reqs
▲ │ ▲
└────────┴─┘
200 requests in ~2 seconds, both windows "within limit"
# A sliding window sees 200 in the last 60 seconds and rejects.
# A token bucket allows a burst up to the bucket size, then throttles to the
# refill rate — which is usually what you actually want.When to use it
- A client discovers it can double its rate at every minute boundary, and the team moves from a fixed window to a sliding window counter.
- A token bucket lets a dashboard load twelve widgets at once while still capping sustained throughput.
- A Lua script makes the limiter atomic, closing a race that let bursts exceed the limit under concurrency.
More examples
Token bucket in Redis, atomically
Passing the timestamp in from the caller rather than using Redis TIME keeps the script deterministic and replication-safe on older Redis versions.
// Read-then-write in two commands has a race. One Lua script does not.
const TOKEN_BUCKET = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3]) -- caller-supplied, ms
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(state[1])
local last = tonumber(state[2])
if tokens == nil then
tokens = capacity
last = now
end
-- Refill for the elapsed time, capped at capacity
local elapsed = math.max(0, now - last) / 1000
tokens = math.min(capacity, tokens + elapsed * refill)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil((capacity / refill) * 1000 * 2))
-- seconds until enough tokens exist for another request of this cost
local retry = 0
if allowed == 0 then retry = math.ceil((cost - tokens) / refill) end
return { allowed, math.floor(tokens), retry }
`;
const sha = await redis.script('LOAD', TOKEN_BUCKET);
export async function consume(key, { capacity, refillPerSecond, cost = 1 }) {
const [allowed, remaining, retryAfter] = await redis.evalsha(
sha, 1, key, capacity, refillPerSecond, Date.now(), cost);
return { allowed: allowed === 1, remaining, retryAfter };
}
// Cost-weight by how expensive the endpoint actually is.
const COST = {
'GET /api/health': 0,
'GET /api/invoices': 1,
'POST /api/invoices': 2,
'POST /api/reports': 25, // a report is not one request's worth of work
'POST /api/exports': 50,
};
export function rateLimit({ capacity = 100, refillPerSecond = 2 } = {}) {
return async (req, res, next) => {
const key = `rl:${req.user?.id ?? req.ip}`;
const cost = COST[`${req.method} ${req.route?.path ?? req.path}`] ?? 1;
if (cost === 0) return next();
const { allowed, remaining, retryAfter } =
await consume(key, { capacity, refillPerSecond, cost })
.catch(() => ({ allowed: null }));
// Store unavailable → degrade to a local limiter, never to "allow all".
if (allowed === null) {
metrics.increment('ratelimit.store_error');
return localFallback.allow(key) ? next()
: res.status(429).json({ error: 'rate_limited' });
}
res.set('RateLimit-Limit', String(capacity));
res.set('RateLimit-Remaining', String(Math.max(0, remaining)));
res.set('RateLimit-Policy', `${capacity};w=${capacity / refillPerSecond}`);
if (!allowed) {
res.set('Retry-After', String(retryAfter));
return res.status(429).json({ error: 'rate_limited', retryAfter });
}
next();
};
}The four algorithms, side by side
The sliding window counter's weighting by elapsed fraction is the whole trick: two integers approximate a log that would otherwise need one entry per request.
// ── 1. Fixed window: one counter, boundary burst ─────────────────────
async function fixedWindow(key, limit, windowS) {
const bucket = `${key}:${Math.floor(Date.now() / 1000 / windowS)}`;
const count = await redis.incr(bucket);
if (count === 1) await redis.expire(bucket, windowS);
return count <= limit;
}
// ✅ one command ❌ up to 2x the limit across a boundary
// ── 2. Sliding window log: exact, memory-hungry ──────────────────────
async function slidingLog(key, limit, windowMs) {
const now = Date.now();
const [, , count] = await redis.multi()
.zremrangebyscore(key, 0, now - windowMs) // drop what fell out
.zadd(key, now, `${now}-${Math.random()}`)
.zcard(key)
.pexpire(key, windowMs)
.exec();
return count[1] <= limit;
}
// ✅ exact ❌ one member per request — costly at high limits
// ── 3. Sliding window counter: the usual production choice ───────────
async function slidingCounter(key, limit, windowS) {
const now = Date.now() / 1000;
const current = Math.floor(now / windowS);
const elapsed = (now % windowS) / windowS; // 0..1 through this window
const [prev, curr] = await redis.mget(`${key}:${current - 1}`, `${key}:${current}`);
// Weight the previous window by how much of it still overlaps.
const estimate = (Number(prev) || 0) * (1 - elapsed) + (Number(curr) || 0);
if (estimate >= limit) return false;
await redis.multi()
.incr(`${key}:${current}`)
.expire(`${key}:${current}`, windowS * 2)
.exec();
return true;
}
// ✅ two keys, cheap ⚠️ approximate (typically within a few percent)
// ── 4. Leaky bucket: smooths completely ──────────────────────────────
// Requests queue and drain at a fixed rate. Use when the DOWNSTREAM system
// cannot absorb bursts at all — a legacy database, a paid third-party API.
// Implemented as a token bucket with capacity == 1 refill interval, or as a
// real queue with a fixed-rate worker.
// ── Choosing ─────────────────────────────────────────────────────────
// general API traffic → token bucket (bursts are normal)
// strict contractual rate → sliding window counter
// protecting a fragile dep → leaky bucket / queue
// internal, low stakes → fixed windowLimiting by the right key, in layers
Logging which layer rejected a request is a small addition that turns an opaque 429 into something support can explain to a customer.
// A single key is always wrong for something. Layer them.
export function layeredRateLimit(req) {
const layers = [];
// 1. Per authenticated principal — the most meaningful identity
if (req.user) {
layers.push({ key: `u:${req.user.id}`, capacity: 300, refill: 5 });
}
// 2. Per tenant — one customer must not starve another
if (req.user?.tenant) {
layers.push({ key: `t:${req.user.tenant}`, capacity: 3000, refill: 50 });
}
// 3. Per IP — catches unauthenticated traffic and one noisy machine
layers.push({ key: `ip:${req.ip}`, capacity: 100, refill: 2 });
// 4. Per API key, if that is the credential
if (req.client?.apiKeyId) {
layers.push({ key: `k:${req.client.apiKeyId}`, capacity: 1000, refill: 20 });
}
// 5. Global, per endpoint — a circuit breaker for a broad campaign
layers.push({ key: `ep:${req.method}:${req.route?.path}`,
capacity: 10_000, refill: 200 });
return layers;
}
export function rateLimitAll() {
return async (req, res, next) => {
for (const layer of layeredRateLimit(req)) {
const { allowed, retryAfter } = await consume(`rl:${layer.key}`, {
capacity: layer.capacity, refillPerSecond: layer.refill,
});
if (!allowed) {
// Log WHICH layer fired — otherwise 429s are undiagnosable.
logger.info({ layer: layer.key, path: req.path }, 'rate limited');
res.set('Retry-After', String(retryAfter));
return res.status(429).json({ error: 'rate_limited', retryAfter });
}
}
next();
};
}
// ⚠️ Getting the IP right is a prerequisite. Behind a proxy without
// `trust proxy` configured, req.ip is the load balancer, every request
// shares one bucket, and the limiter either blocks everyone or nobody.
app.set('trust proxy', 1);
// ⚠️ And never `trust proxy: true` on a public app — the client then prepends
// its own X-Forwarded-For and gets a fresh bucket per request.
Discussion