You sell an API plan at 100 requests per minute. You implement it the obvious way: a counter in Redis, keyed by customer and by minute, incremented on every request and rejected past 100.
A customer sends 100 requests at 12:03:59 and another 100 at 12:04:00.
Both minutes are compliant. Neither counter exceeded its limit. Your origin just absorbed 200 requests inside two seconds from a customer paying for 100 a minute, and every dashboard you own will insist the rate limiter worked.
This is the fixed window boundary burst, and it is not an edge case you can dismiss. It is a property of counting per calendar minute, it is trivially discoverable by anyone watching their own X-RateLimit-Reset header, and the peak it permits is exactly twice what you sold.
#The boundary burst is exactly double, and it is not random
Fixed window counting works by rounding the current time down to a window and using that as part of the key:
const window = Math.floor(Date.now() / 60_000)
const key = `rl:${customerId}:${window}`
const count = await redis.incr(key)
if (count === 1) await redis.expire(key, 60)
if (count > limit) return tooManyRequests()Three lines, atomic, cheap, and it does enforce an average. Over any hour, that customer cannot exceed 6000 requests.
The problem is that the limit resets on a boundary rather than relative to the caller's own history. Every request in the final instant of one window and every request in the first instant of the next are counted separately, so the true worst case over any sliding 60 second span is 2 × limit, arriving as fast as the client can open connections.
It is worth being clear about who this hurts. It is not usually a malicious actor, though it will be eventually, because the boundary is publicly observable. It is far more often a well behaved client with a cron job, a queue drain, or a retry wave that happens to align with the clock. Those all produce burst-shaped traffic by nature, and burst-shaped traffic is exactly what the fixed window fails to see.
#A sliding log is exact, and you cannot afford it
The precise fix is to stop counting per window and start counting per caller over the trailing 60 seconds. Keep a timestamp for every request in a sorted set, drop the ones that have aged out, and count what is left:
const now = Date.now()
const key = `rl:${customerId}`
const pipeline = redis.multi()
pipeline.zremrangebyscore(key, 0, now - 60_000)
pipeline.zcard(key)
pipeline.zadd(key, now, `${now}:${crypto.randomUUID()}`)
pipeline.expire(key, 60)
const [, count] = await pipeline.exec()
if (count >= limit) return tooManyRequests()This is exact. There is no boundary, no approximation, and no burst you did not authorise.
It also stores one sorted set member per request per caller. At 100 requests a minute across 10,000 active customers, that is a million members held continuously, each with a score, a member string and sorted set overhead, and every request pays for a range trim on top. Memory grows with your traffic, not with your customer count, which is the wrong axis to scale on. The busier you get, the more the limiter costs, precisely when you can least afford it.
A sliding log is the right answer for a small number of high value keys. Login attempts per account, password resets, anything where you have thousands of keys rather than millions of events. It is the wrong answer for general API quota.
#The sliding window counter is the approximation worth shipping
There is a middle option that costs two integers per caller and removes almost all of the burst. Keep the current fixed window count and the previous one, then weight the previous by however much of it is still inside the trailing window:
const windowMs = 60_000
const now = Date.now()
const current = Math.floor(now / windowMs)
const elapsed = now % windowMs
const [prev, curr] = await redis.mget(
`rl:${customerId}:${current - 1}`,
`rl:${customerId}:${current}`
)
const estimate =
Number(prev ?? 0) * (1 - elapsed / windowMs) + Number(curr ?? 0)
if (estimate >= limit) return tooManyRequests()Forty seconds into the current minute, two thirds of the previous minute is still within the trailing 60 seconds, so two thirds of its count is charged against the caller. The 100 requests sent at 12:03:59 keep counting against the customer well into 12:04, and the second burst is refused.
The approximation assumes requests were spread evenly across the previous window. When they were not, the estimate is slightly off in either direction. In exchange you store two counters per caller no matter how much traffic flows through them, and both are plain INCR keys with a TTL. This is broadly the approach Cloudflare described for rate limiting at their scale, and the accuracy is good enough that the failure mode stops being interesting.
#A token bucket separates the rate from the burst
Every algorithm above answers one question: how many requests in the last minute. A token bucket answers two, and the second one is the one your customers actually care about.
A bucket holds up to capacity tokens and refills at rate tokens per second. Each request takes one. Empty bucket, request refused.
The steady state throughput is the refill rate. The burst tolerance is the capacity. They are separate numbers, which means you can finally express the thing every API plan wants to say: 100 requests per minute, with a burst allowance of 20. A fixed window cannot express that. A sliding window cannot express it either. Both conflate the sustained rate with the instantaneous one, and then you spend a support cycle explaining to a customer why their perfectly reasonable batch job is being rejected.
The other property that matters is that it is smooth. Nothing resets. A caller who has been quiet has tokens waiting; a caller who has been hammering gets served at exactly the refill rate. There is no boundary to discover and no cliff to fall off.
#The arithmetic has to happen inside Redis
Read the bucket, compute the refill, write it back. Three steps, and if you do them from your application they are three round trips with the state unguarded in between.
Redis executes each command atomically, but a read-modify-write built from separate commands is not one command. Two instances handling the same customer at the same moment will both read the same token count, both decide there is one available, and both spend it. Under low load you will never see it. Under the load that made you build a rate limiter, you will see it constantly, and it will look like the limiter is simply wrong.
Push the whole operation into a script so it runs as one unit:
-- KEYS[1] bucket key
-- ARGV[1] capacity ARGV[2] refill tokens per second
-- ARGV[3] cost of this call
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
-- Redis' own clock, not the caller's. Application servers disagree about the
-- time by more than the window you are enforcing, and a bucket refilled from a
-- fast server's clock hands out tokens that were never earned.
local time = redis.call('TIME')
local now = tonumber(time[1]) + tonumber(time[2]) / 1000000
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(bucket[1])
local ts = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
ts = now
end
tokens = math.min(capacity, tokens + (now - ts) * rate)
local allowed = 0
if tokens >= cost then
allowed = 1
tokens = tokens - cost
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- Expire once a full bucket would have refilled anyway. An idle caller's key
-- evicts itself, so memory tracks active callers rather than every caller you
-- have ever had.
redis.call('PEXPIRE', KEYS[1], math.ceil((capacity / rate) * 1000) + 1000)
local retry_after = 0
if allowed == 0 then
retry_after = math.ceil((cost - tokens) / rate)
end
return { allowed, math.floor(tokens), retry_after }Two details in there are worth more than the rest of the script.
The clock comes from Redis. Passing Date.now() in from the application seems harmless until you have four API servers whose clocks differ by a couple of hundred milliseconds. The bucket then refills according to whichever server happens to be fastest, and your limit quietly becomes higher than the one you sold. One clock, held by the one process that serialises everything, removes the whole class of problem. Since Redis 5 scripts replicate their effects rather than the script itself, so calling TIME inside one is safe.
The TTL is derived, not guessed. Set it to the time a fully drained bucket needs to refill completely, because after that the stored state is indistinguishable from a fresh bucket. Idle keys evict themselves and memory is bounded by concurrently active callers.
#A counter per instance is your limit multiplied by your instance count
This one deserves stating plainly because it survives code review so easily.
An in-memory counter, a Map keyed by customer inside your process, enforces the limit per process. Run four instances behind a load balancer and your 100 per minute plan is a 400 per minute plan. Autoscale to twelve under load and it is a 1200 per minute plan, which means the limit relaxes exactly when traffic is heaviest.
Local limiters have one legitimate use, which is protecting a single process from its own resource exhaustion. As a way to enforce a quota you have sold, shared state is not an optimisation. It is the requirement.
#Tell the caller what happened, in headers they can use
A rejection with no information is a rejection the client will retry immediately, and now you are paying for both requests.
Return 429 Too Many Requests with a Retry-After, and expose the state of the limit on every response so a well behaved client can pace itself before it hits the wall:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1756732992Retry-After is the one that matters, and the Lua script above already computes it, because the bucket knows exactly how long until the next token exists. A client guessing with exponential backoff cannot beat a server that knows the answer. This is the same argument as honouring the server's instruction rather than your own backoff calculation, and it applies with more force here, because your limiter is the thing generating the errors it is asking clients to back off from.
The IETF has a draft standardising this under RateLimit and RateLimit-Policy fields. Until that lands, the X-RateLimit-* triple is what every HTTP client library and every developer already recognises, so send those and add the standard fields alongside when it settles.
One more thing worth doing: if the endpoint being limited is a write, make sure it is safe for the client to retry once the limit clears. A 429 is an explicit invitation to send the request again, which means everything about idempotency keys applies to it.
#Decide now what happens when Redis is unavailable
Your rate limiter is a dependency on the request path of every endpoint you own. At some point it will be slow, or unreachable, and the behaviour you get then is the behaviour you chose, whether or not you chose it deliberately.
Fail open and requests are served without enforcement. You stay available and you are briefly unprotected.
Fail closed and every request is rejected. Your limiter's outage becomes a total outage, converting a degraded dependency into a full one.
For paid quota, fail open is almost always correct. Serving a customer more than they paid for during a Redis incident costs you very little; refusing every request from every customer costs you the incident report. For abuse-facing surfaces, login attempts, password resets, signup, fail closed, because the thing you are protecting is not revenue.
Whichever you pick, put a tight timeout on the limiter call, something in the low tens of milliseconds. A rate limiter that adds 500ms to every request during a Redis hiccup has already taken you down, and it did it while returning 200.
#Which one to ship
If you are enforcing a quota you sell, use a token bucket in a Redis script. It expresses rate and burst as separate numbers, it computes an accurate Retry-After for free, it holds one small hash per active caller, and it has no boundary for anyone to discover.
If you need exactness on a small, high value set of keys, use a sliding log and accept that it costs memory proportional to traffic.
If you already run a fixed window and cannot replace it today, add the previous window term. It is one extra GET and one line of arithmetic, and it removes the double-your-limit burst that made this post worth writing.
What you should not do is keep counting per calendar minute and describe the result to customers as a limit. It is an average, and averages are not what anyone means when they ask how many requests per minute they are allowed.