Design a Distributed Rate Limiter
Where the counter lives, what coordination costs, and how to be approximately right about a limit at high request rates.
consume(key, { limit, windowMs }) -> { allowed, remaining, retryAfterMs }A rate limiter decides, for every request, whether it may proceed. It exists to protect capacity you cannot buy back on demand: a connection pool, a third-party API quota, a payment processor, a single-threaded worker.
Where the counter lives
An in-process counter costs nothing and is wrong by a factor of your fleet size: fifty app servers each enforcing 100 requests per minute enforce 5,000. Moving the counter into shared storage, normally Redis, makes it correct and puts a network round trip plus a new failure domain on every request. Stripe describes running four limiters in series against Redis for exactly this reason, and only the first of them is a token bucket: a request rate limiter, a concurrent-requests limiter that caps in-flight work rather than arrivals, and two load shedders that reserve capacity for critical traffic instead of counting per caller.
Choosing what to count
| Algorithm | State per key | Trade-off |
|---|---|---|
| Fixed window | One counter | Cheapest; admits up to twice the limit across a window boundary |
| Sliding window log | A timestamp per request | Exact; memory grows with traffic |
| Sliding window counter | Two counters | Near-exact and O(1); Cloudflare reported roughly 0.003% wrong decisions over 400 million requests |
| Token bucket | Tokens plus last-refill time | Permits a configured burst, which is usually what callers actually need |
Being wrong on purpose
Below a few hundred requests per second per key, go straight to the shared counter. Above that, lease blocks of permits to each process and spend them in memory. The fleet total still respects the limit; what you give up is fairness, because a quiet process can hold permits a busy one needed. Decide in advance whether a Redis outage fails open or fails closed. Both are defensible. Not deciding is not.
Example
// Sliding-window-counter limiter on Redis (node-redis 4.x).
// Two integers per key instead of one timestamp per request: weight the
// previous window by how far into the current window we are.
// estimate = current + previous * (1 - elapsed / window)
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
// The whole decision runs inside Redis, so the read-modify-write cannot
// interleave with another app server, and the TTL cannot be orphaned by a
// crash between INCR and PEXPIRE. KEYS and ARGV are 1-indexed in Lua.
const SCRIPT = `
local window = tonumber(ARGV[1]) -- window length in milliseconds
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3]) -- caller's clock, so every node agrees
local bucket = math.floor(now / window)
local curKey = KEYS[1] .. ':' .. bucket
local prevKey = KEYS[1] .. ':' .. (bucket - 1)
local cur = tonumber(redis.call('GET', curKey)) or 0
local prev = tonumber(redis.call('GET', prevKey)) or 0
local elapsed = (now % window) / window
local estimate = cur + prev * (1 - elapsed)
if estimate >= limit then
-- Retry once enough of the previous window has aged out.
return { 0, 0, math.ceil(window - (now % window)) }
end
redis.call('INCR', curKey)
redis.call('PEXPIRE', curKey, window * 2) -- keep the previous window alive too
return { 1, math.max(0, math.floor(limit - estimate - 1)), 0 }
`;
const sha = await redis.scriptLoad(SCRIPT); // load once, EVALSHA per request
export async function consume(key, { limit, windowMs }) {
try {
const [allowed, remaining, retryAfterMs] = await redis.evalSha(sha, {
keys: [`rl:${key}`],
arguments: [String(windowMs), String(limit), String(Date.now())],
});
return { allowed: allowed === 1, remaining, retryAfterMs };
} catch (err) {
// Fail open: a Redis outage must not become a total outage. For abuse
// protection you may want the opposite. Make it an explicit choice.
return { allowed: true, remaining: limit, retryAfterMs: 0, degraded: true };
}
}When to use it
- Cap a public API at 1,000 requests per minute per API key across a fleet of app servers.
- Stay inside a third-party quota (a payment gateway, a geocoder) that bills or bans on overage.
- Slow down credential-stuffing attempts per account and per source address without locking real users out.
- Shed non-critical traffic during an incident so the checkout path keeps its database capacity.
More examples
HTTP middleware and the response the caller has to act on
The limiter is only half the design; the other half is telling the caller what to do. Reach for this shape on any public API: a 429 without a Retry-After turns a well-behaved client into a retry storm, because its only option is to guess. Send the headers on allowed requests too, so a client can slow down before it is refused.
// Express middleware. 429 Too Many Requests is defined in RFC 6585;
// Retry-After is RFC 9110 section 10.2.3 and is the only header here that is
// actually standardised. The RateLimit-* fields are an IETF draft: widely
// copied, not a standard, so send them but do not rely on clients reading them.
import { consume } from './limiter.js';
export function rateLimit({ limit, windowMs, identify }) {
return async function (req, res, next) {
// `identify?.(req) ?? req.ip` is shorthand for the two longhand lines below.
// Note that `??` falls back only on null/undefined, where `||` would also
// fall back on an empty string - which a tenant id could legitimately be.
const key = identify?.(req) ?? req.ip;
// let key = identify == null ? undefined : identify(req);
// if (key === null || key === undefined) key = req.ip;
const { allowed, remaining, retryAfterMs } = await consume(key, { limit, windowMs });
res.set('RateLimit-Limit', String(limit));
res.set('RateLimit-Remaining', String(remaining));
if (allowed) return next();
// Retry-After is whole seconds. Round up: rounding down invites the client
// straight back into another 429.
res.set('Retry-After', String(Math.ceil(retryAfterMs / 1000)));
return res.status(429).json({ error: 'rate_limited', retry_after_ms: retryAfterMs });
};
}Local permits with a global budget, for hot keys
This is the local-plus-global approximation. It converts one Redis call per request into one per twenty, which is the difference between a limiter that works at 50,000 requests per second and one that becomes the bottleneck. Reach for it only for the handful of keys that need it; for everything else the extra moving part is not worth the saving.
// Two-tier limiter. The hot path is in-process; Redis is consulted once per
// block of permits rather than once per request. Use it when a single key is
// taking thousands of requests per second and the round trip is the bottleneck.
const BLOCK = 20;
const leases = new Map(); // key -> { permits, expiresAt }
export async function consumeBatched(key, opts) {
const now = Date.now();
const lease = leases.get(key);
if (lease && lease.expiresAt > now && lease.permits > 0) {
lease.permits -= 1;
return { allowed: true, source: 'local' };
}
// Deduct the whole block from the global counter up front. Because the
// permits are spent from the shared budget at lease time, the fleet cannot
// exceed the limit. What it can do is distribute it badly: a process that
// goes quiet sits on permits a busy process needed. That unfairness, not
// over-admission, is the thing you are accepting here.
const { allowed, granted } = await leaseBlock(key, { ...opts, want: BLOCK });
// `{ ...opts, want: BLOCK }` is shorthand for
// Object.assign({}, opts, { want: BLOCK }) - the same object, spelled shorter.
if (!allowed || granted === 0) return { allowed: false, source: 'global' };
leases.set(key, { permits: granted - 1, expiresAt: now + opts.windowMs });
return { allowed: true, source: 'global' };
}
// Unspent permits must expire with the window, or a process holding a stale
// lease keeps admitting traffic the budget no longer covers.
setInterval(() => {
const now = Date.now();
for (const [key, l] of leases) if (l.expiresAt <= now) leases.delete(key);
}, 1000).unref();Token bucket, when callers legitimately burst
Weighted costs are why token buckets survive contact with real APIs: a list endpoint and a bulk import should not both count as one request. Note that the bucket is never swept by a timer; it is refilled by arithmetic at read time, so an idle key costs nothing but its TTL. Use this for per-tenant API quotas, and a window counter for things like login attempts where bursts are the attack.
// Token bucket in Redis. Unlike a window counter, this one stores no counts:
// it stores the token level and when it was last refilled, and derives the
// current level on read. A client that has been idle may spend its whole
// bucket at once, which is the point - batch jobs arrive in batches.
const TOKEN_BUCKET = `
local capacity = tonumber(ARGV[1]) -- maximum burst
local refill = tonumber(ARGV[2]) -- tokens added per second
local now = tonumber(ARGV[3]) -- milliseconds
local cost = tonumber(ARGV[4]) -- tokens this request costs
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil then tokens = capacity; ts = now end
-- Refill lazily: no background job, no timer, just arithmetic on elapsed time.
tokens = math.min(capacity, tokens + ((now - ts) / 1000) * refill)
if tokens < cost then
local waitMs = math.ceil(((cost - tokens) / refill) * 1000)
return { 0, 0, waitMs }
end
tokens = tokens - cost
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- Expire after the time it takes an empty bucket to refill completely; past
-- that point a missing key and a full key mean the same thing.
redis.call('PEXPIRE', KEYS[1], math.ceil((capacity / refill) * 1000) + 1000)
return { 1, math.floor(tokens), 0 }
`;
// A write endpoint costing more than a read is just a different `cost`:
// consumeTokens(key, { capacity: 100, refill: 10, cost: 1 }) // GET
// consumeTokens(key, { capacity: 100, refill: 10, cost: 10 }) // bulk import
Discussion