API Rate Limiting: Token Bucket, Redis, and 429 Headers
Rate limit an API properly: fixed window vs sliding window vs token bucket, a Redis implementation, the RateLimit headers to send, and per-user keying.
Rate limiting gets added after the incident. A scraper discovers an expensive endpoint, or a client ships a retry loop with no backoff, and suddenly the database is saturated by one caller. The fix is not hard. Choosing the wrong algorithm, or limiting on the wrong key, is what turns it into a support problem instead.
Four API rate limiting algorithms
They differ in how they handle bursts, and that difference is the whole decision.
| Algorithm | Memory | Weakness |
|---|---|---|
| Fixed window | One counter per key | Allows double the limit across a window boundary |
| Sliding log | One timestamp per request | Exact, and memory grows with traffic |
| Sliding window counter | Two counters per key | Approximate, but close enough |
| Token bucket | Two numbers per key | Deliberately permits bursts |
Why fixed window is not enough
A limit of 100 per minute, implemented as a counter that resets on the minute, lets a client send 100 requests at 10:00:59 and another 100 at 10:01:00. Two hundred requests in one second, inside the limit as written. If you are rate limiting to protect a resource, that burst is exactly what you were trying to prevent.
It is still the right choice sometimes. For a coarse abuse limit like 10,000 per hour, the boundary effect does not matter and one integer per key is very cheap.
Token bucket, and why I default to it
A bucket holds tokens up to a maximum. Tokens refill at a steady rate. Each request removes one, and a request with no token available is rejected.
This models real usage better than a flat window. A client that has been idle accumulates tokens and can burst, which is usually what you want: someone opening a dashboard fires twelve requests at once and that is legitimate. Sustained load is still capped at the refill rate.
type Bucket = { tokens: number; lastRefill: number };
const CAPACITY = 20; // burst allowance
const REFILL_PER_SECOND = 2; // sustained rate
function consume(bucket: Bucket, now: number): boolean {
// Refill lazily rather than on a timer: no background job, and the
// arithmetic is identical.
const elapsed = (now - bucket.lastRefill) / 1000;
bucket.tokens = Math.min(CAPACITY, bucket.tokens + elapsed * REFILL_PER_SECOND);
bucket.lastRefill = now;
if (bucket.tokens < 1) return false;
bucket.tokens -= 1;
return true;
}Lazy refill is the part worth copying. There is no scheduled job topping up buckets; you compute how much time has passed when the request arrives. That is what makes the whole thing two numbers in Redis.
Doing it in Redis, atomically
In-memory counters break the moment you run two instances, because each one enforces the limit independently and a client with three servers behind a load balancer gets three times the allowance.
Moving to Redis introduces a different problem: read, modify, write is not atomic across concurrent requests. Two requests arriving together both read one token remaining and both proceed. A Lua script runs server-side in a single step and removes the race.
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]) -- milliseconds
local requested = tonumber(ARGV[4])
local state = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil then
tokens = capacity
ts = now
end
local elapsed = math.max(0, now - ts) / 1000
tokens = math.min(capacity, tokens + elapsed * refill)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
-- Expire idle buckets so keys do not accumulate forever.
redis.call('PEXPIRE', key, math.ceil((capacity / refill) * 1000) + 1000)
return { allowed, tokens }
`;
export async function checkLimit(key: string) {
const [allowed, remaining] = (await redis.eval(
TOKEN_BUCKET,
{ keys: [`rl:${key}`], arguments: ['20', '2', String(Date.now()), '1'] }
)) as [number, number];
return { allowed: allowed === 1, remaining: Math.floor(remaining) };
}The PEXPIRE matters more than it looks. Without it, every key that ever hit your API stays in Redis forever, and on a public endpoint that is an unbounded memory leak with a slow fuse.
What to limit on
The key you choose determines whether the limit protects you or annoys your users.
IP address is the only option for unauthenticated traffic, and it is blunt. An office, a university or a mobile carrier NAT puts hundreds of people behind one address, so a per-IP limit tuned for one person locks out a building. Keep IP limits generous and treat them as abuse protection, not as quota.
User or API key is what you want wherever the request is authenticated. It is fair, it survives the user changing networks, and it lets you sell tiers.
Both, in practice: a loose IP limit in front for unauthenticated abuse, and a per-user limit behind it for quota.
Tell the client what happened
A bare 429 with no information forces clients to guess, and clients guess by retrying immediately. Send the headers.
export async function rateLimit(req: Request, res: Response, next: NextFunction) {
const key = req.user?.id ?? req.ip;
const { allowed, remaining, resetSeconds } = await checkLimit(key);
// The IETF draft header format, now widely supported.
res.setHeader('RateLimit-Limit', LIMIT);
res.setHeader('RateLimit-Remaining', Math.max(0, remaining));
res.setHeader('RateLimit-Reset', resetSeconds);
if (!allowed) {
// Retry-After is the one clients and crawlers actually respect.
res.setHeader('Retry-After', resetSeconds);
return res.status(429).json({
error: {
code: 'RATE_LIMITED',
message: `Too many requests. Retry in ${resetSeconds}s.`,
},
});
}
next();
}Retry-After is the important one. It is standard, well understood, and well-behaved HTTP clients honour it automatically. The RateLimit-* family lets a good client slow down before it hits the wall rather than after.
The error body follows the same shape as every other error in the API, which is the point made in REST API design: one error structure means clients write one handler.
Backing off on the client side
async function requestWithRetry(url: string, attempt = 0): Promise<Response> {
const res = await fetch(url);
if (res.status !== 429 || attempt >= 4) return res;
const retryAfter = Number(res.headers.get('Retry-After'));
// Exponential backoff with jitter, so a thousand clients that were
// limited together do not all return at the same instant.
const backoff = Number.isFinite(retryAfter)
? retryAfter * 1000
: 2 ** attempt * 500 + Math.random() * 500;
await new Promise((r) => setTimeout(r, backoff));
return requestWithRetry(url, attempt + 1);
}The jitter is not optional at scale. Synchronised retries produce a thundering herd that arrives together, gets limited together, and comes back together, which is a self-sustaining outage.
Not all endpoints deserve the same limit
One global limit is easy and wrong. A cached GET costs almost nothing; a report generator costs seconds of CPU; a login endpoint is where credential stuffing happens.
| Endpoint type | Rough limit | Keyed on |
|---|---|---|
| Cheap reads | Generous, 100+/min | User |
| Writes | Moderate, 20-30/min | User |
| Expensive reports, exports | Tight, a few per minute | User |
| Login, password reset | Very tight, 5-10/hour | IP and account |
| Anything calling a paid API | Tight, and cost-aware | User |
That last row is worth singling out. An endpoint that forwards to a metered third-party service turns request volume directly into money, and an unlimited one is a way for a stranger to spend your budget. This is the specific risk I raised in LLM API integration, where the per-request cost is high enough that a short burst is expensive.
Limiting login attempts on both IP and account is deliberate. Per-IP alone misses a distributed attack on one account; per-account alone lets one IP spray thousands of accounts with common passwords. You need both counters.
Roll out with logging first
The most common way to get this wrong is to deploy a limit tuned from intuition and discover it was set below what a legitimate heavy user does. Run it in shadow mode first: compute the decision, log what would have been blocked, enforce nothing.
const { allowed } = await checkLimit(key);
if (!allowed) {
if (ENFORCE_LIMITS) {
return res.status(429).json(/* ... */);
}
// Shadow mode: measure before you enforce.
logger.warn({ key, path: req.path }, 'would have rate limited');
}
next();A week of that data tells you where the real ceiling is. Then set the limit above your heaviest legitimate user and turn enforcement on.
What I would ship
Token bucket in Redis behind a Lua script, keyed on user ID with an IP fallback, different limits per endpoint class, standard headers including Retry-After, and key expiry so Redis does not grow forever. Shadow mode for a week before enforcing.
And use a maintained library rather than the code above if you can. The implementations here are to show what is happening underneath, because when a limit behaves unexpectedly the fix depends on knowing which algorithm you are running.
Rate limiting is one of several decisions that are much cheaper to make before an API has consumers. The rest of them, error shape, pagination and versioning, are in REST API design.
Frequently asked questions
Which rate limiting algorithm should I use?
Token bucket for most APIs. It allows a burst from an idle client, which is usually legitimate, while capping sustained load at the refill rate, and it needs only two numbers per key. Fixed window is cheaper but lets a client send double the limit across a window boundary, which matters whenever you are protecting a real resource.
Should I rate limit by IP or by user?
By user or API key wherever the request is authenticated, because it is fair and survives network changes. IP limits are unavoidable for unauthenticated traffic but blunt: offices, universities and mobile carriers put hundreds of people behind one address. In practice use both, with a generous IP limit for abuse and a per-user limit for quota.
What headers should a 429 response include?
Retry-After above all, since well-behaved HTTP clients and crawlers honour it automatically. Add RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset so a good client can slow down before hitting the wall rather than after. A bare 429 with no information leads clients to retry immediately, which makes the problem worse.
Why does rate limiting need Redis and a Lua script?
In-memory counters break with more than one instance, because each enforces the limit independently and a client behind a load balancer gets a multiple of the intended allowance. Redis fixes that, but read-modify-write is not atomic across concurrent requests, so two requests can both see the last token. A Lua script runs server-side in one step and removes the race.


