A Redis token bucket keyed by API key, with limit headers on every response and a Retry-After when you say no.
In-memory rate limiting breaks the moment you run two instances, because each process counts on its own and a client gets double the limit. Moving the counter to Redis fixes that, and a token bucket gives clients a burst allowance without letting them hammer you forever. Here is the version I actually ship.
Why a token bucket
A fixed window resets all at once, so everyone slams the API at the top of the minute. A token bucket refills gradually: each key gets a bucket of tokens, every request spends one, and tokens drip back at a steady rate. Clients get smooth throughput and a small burst, which is what real usage looks like.
The Redis script
The check has to be atomic, or two concurrent requests both read the same count and both pass. A small Lua script runs the read, refill, and decrement as one operation inside Redis.
local tokens = tonumber(redis.call("get", KEYS[1]) or ARGV[1])
local refill = (ARGV[2] - (redis.call("get", KEYS[2]) or ARGV[2])) * ARGV[3]
tokens = math.min(ARGV[1], tokens + refill)
if tokens < 1 then return -1 end
redis.call("set", KEYS[1], tokens - 1)
return tokens - 1Keying by API key rather than IP is the important call. IPs are shared behind corporate proxies and mobile carriers, so an IP limit punishes the wrong people. The API key is the actual unit of accountability.
Tell the client what is happening
A 429 with no information is hostile. Every response carries the limit, the remaining tokens, and on rejection a Retry-After so a well-behaved client backs off instead of retrying in a tight loop.
res.set("X-RateLimit-Limit", String(max));
res.set("X-RateLimit-Remaining", String(remaining));
if (remaining < 0) {
res.set("Retry-After", String(retrySeconds));
return res.status(429).json({ error: "rate_limited" });
}Different limits for different routes
A login endpoint and a read endpoint should not share a budget. The middleware takes the bucket size and refill rate as arguments, so auth routes get a tight limit while cheap reads get a generous one. One middleware, configured per route, covers the whole API.
The result holds up across instances, refills smoothly, and treats clients like partners by telling them exactly where they stand. It is maybe forty lines of real code, and it has never been the thing that fell over under load.