Rate limiting for APIs: picking an algorithm, where to keep state and how to reject Intermediate
Estimated reading time: 16 min read
The problem
Section titled “The problem”A search API can handle about 1,000 requests/second. One customer runs a data sync script that fires 3,000 requests/second for 5 minutes. The database saturates and p99 latency for every other customer shoots up.
Rate limiting answers three separate questions:
- How to count? The algorithm decides which bursts get through.
- Where to count? In each instance’s RAM or in a shared store such as Redis.
- How to reject? Status code, headers and body so the client knows when to try again.
Getting one right and the other two wrong still fails: a correct algorithm where every Pod counts on its own multiplies the effective limit by the number of Pods.
Quick pick
Section titled “Quick pick”| Algorithm | Memory per key | Burst | Accuracy | Complexity |
|---|---|---|---|---|
| Fixed window | 1 counter + window id | Up to 2× limit around a window boundary | Poor at boundaries | Very low (INCR + EXPIRE) |
| Sliding window log | 1 timestamp per request in the window, O(limit) | Never exceeds the limit in any sliding window | Exact | Medium; RAM-heavy for large limits |
| Sliding window counter | 2 counters (current and previous window) | Roughly the limit | Approximate, assumes evenly spread requests | Low |
| Token bucket | 2 numbers: remaining tokens + last update time | Allows bursts up to capacity, then enforces the refill rate |
Exact per the model | Low |
| Leaky bucket | A queue or 1 timestamp | Smooths: steady output, bursts are queued or rejected | Exact per the model | Low to medium (with a queue) |
Default for public APIs: token bucket (or GCRA, a variant that stores a single timestamp). It allows clients reasonable bursts, caps the long-term rate, and needs only two numbers per key.
Five algorithms
Section titled “Five algorithms”Fixed window: cheap but leaky at the boundary
Section titled “Fixed window: cheap but leaky at the boundary”Split time into fixed slots (for example every minute), count requests in the slot, reject above the limit. When a new slot starts, the counter resets to 0.
The boundary hole with a limit of 100 requests/minute:
| Time | Requests | Window | Result |
|---|---|---|---|
| 00:59.5 | 100 | minute 0 (count 0 → 100) | all allowed |
| 01:00.0 | 100 | minute 1 (count restarts at 0) | all allowed |
Result: 200 requests within about 0.5 seconds, twice the per-minute limit, without breaking any rule of the algorithm.
Sliding window log: exact but RAM-hungry
Section titled “Sliding window log: exact but RAM-hungry”Store the timestamp of each request (usually in a sorted set). On each new request: remove timestamps older than now - window, count the rest, compare to the limit.
Perfectly accurate, but a limit of 10,000 requests/hour means up to 10,000 elements for each key. Good for small, sensitive limits (login, sending OTPs), not for large quotas.
Sliding window counter: a cheap approximation
Section titled “Sliding window counter: a cheap approximation”Keep counters for the current and previous window and estimate by the fraction of time elapsed:
rate ≈ previous_count × (window − elapsed) / window + current_countCloudflare’s example with a limit of 50/minute: 42 requests in the previous minute, 15 seconds into this minute with 18 requests → 42 × 45/60 + 18 = 49.5. The next request will be blocked.
It assumes the previous minute’s requests were evenly spread, so it is an approximation. Cloudflare reported over 400 million requests: about 0.003% of requests were wrongly allowed or blocked.
Token bucket: controlled bursts
Section titled “Token bucket: controlled bursts”The bucket holds at most capacity tokens and is refilled evenly at rate tokens/second. Each request takes 1 token (or more if the request is expensive); when tokens run out, reject.
No timer is needed to refill tokens: when handling a request, compute tokens = min(capacity, tokens + elapsed × rate). The two parameters separate two questions: maximum burst (capacity) and long-term rate (rate).
The client has been quiet for a while, so the bucket is full: 10 tokens. That is exactly the maximum allowed burst.
Leaky bucket: smoothing the output
Section titled “Leaky bucket: smoothing the output”Requests go into a “bucket” and leak out at a fixed rate; when the bucket is full it overflows (reject). There are two interpretations: a queue (requests are delayed, then processed) or a meter (it only measures, equivalent to a token bucket viewed in reverse).
NGINX limit_req uses the leaky bucket method: requests above rate are delayed until they exceed burst, then rejected; add nodelay to process requests within the burst immediately.
Note: the default rejection status of limit_req_status is 503, so change it to 429 if you want clients to tell overload apart from quota exhaustion.
Demo: the same burst, two algorithms
Section titled “Demo: the same burst, two algorithms”A simulation with a fake clock, no Redis needed. Limit of 10 requests/second for both: fixed window of 1,000 ms, token bucket with capacity 10, refill 10/second. The client fires 10 requests at 990 ms, 10 at 1,000 ms, 10 at 1,500 ms.
npx --prefix examples ts-node examples/src/rate-limiting/burst-demo.tslimit: 10 req/s | fixed window 1000 ms | token bucket capacity 10, refill 10/st(ms) sent fixed-window token-bucket tokens-left 990 10 10 allowed 10 allowed 0.0 1000 10 10 allowed 0 allowed 0.1 1500 10 0 allowed 5 allowed 0.1accepted in 990..1000 ms (10 ms): fixed-window=20, token-bucket=10total accepted out of 30: fixed-window=20, token-bucket=15Reading the output:
- Fixed window lets 20 requests through in 10 ms, twice the limit, because 1,000 ms is a window boundary.
- Token bucket never allows more than
capacityin one burst, yet after 500 ms it lets 5 requests through again: it does not punish the client longer than necessary. - Fixed window blocks the entire 1,500 ms round even though the client waited half a second; token bucket distributes more fairly over time.
Where state lives: per instance or shared Redis
Section titled “Where state lives: per instance or shared Redis”| Storage | Pros | Cons | Use when |
|---|---|---|---|
| Per-instance RAM | No extra network hop, no store dependency | Effective limit ≈ limit × number of instances; resets on Pod restart; skewed when the load balancer distributes unevenly | Local overload protection (protecting that instance itself) |
| Shared Redis | One source of truth for all instances | +1 round-trip per request; Redis becomes a dependency on the hot path | Quotas per user/API key, limits sold by plan |
| Gateway / edge (NGINX, API gateway, CDN) | Blocks before spending application resources | No business context (tenant, cost) | Coarse IP-based layer, flood protection |
Atomicity: read-compute-write must be one step
Section titled “Atomicity: read-compute-write must be one step”A common mistake: GET the tokens, compute in the app, then SET. Two instances both read “1 token left” and both allow the request.
INCR+EXPIREis enough for fixed window, becauseINCRis atomic on its own.- MULTI/EXEC batches commands, but does not let you read results midway to decide; you must combine it with
WATCHand retry on conflict. Redis also does not roll back commands that already ran inside a transaction. - A Lua script is the cleanest option: Redis guarantees the script runs atomically, blocking all other server activity while it runs.
A token bucket sketch in Lua. Tested on Redis 7.4 with capacity 10 and refill 10 tokens/second: of 12 back-to-back requests, 10 were accepted and 2 rejected with retry_ms = 99. You still need load tests and failover tests in your own system before using it:
-- KEYS[1] = bucket key, e.g. rl:{apikey:abc}-- ARGV[1] = capacity, ARGV[2] = refill tokens/second, ARGV[3] = request costlocal capacity = tonumber(ARGV[1])local rate = tonumber(ARGV[2])local cost = tonumber(ARGV[3])
-- cost larger than capacity can never have enough tokens: return -1 so the app reports an error instead of 429 + Retry-Afterif cost > capacity then return { 0, 0, -1 }end
local t = redis.call('TIME') -- Redis's clock, not the app'slocal now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')local tokens = tonumber(state[1]) or capacitylocal ts = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) / 1000 * rate)
local allowed, retry_ms = 0, 0if tokens >= cost then tokens = tokens - cost allowed = 1else retry_ms = math.ceil((cost - tokens) / rate * 1000)end
redis.call('HSET', KEYS[1], 'tokens', tostring(tokens), 'ts', tostring(now))redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) + 1000)return { allowed, math.floor(tokens), retry_ms }Deliberate choices in the sketch:
- Key names go through
KEYS, not string concatenation inside the script; Redis requires this for the script to work correctly on Cluster as well. TIMEinside the script is safe because since Redis 5.0 scripts are replicated by effects (Redis 7.0 dropped verbatim mode entirely), so replicas receive theHSETcommand with the computed value instead of re-running the script.PEXPIREso keys of inactive clients disappear on their own once the bucket has refilled.retry_msis used to fillRetry-After.- A value of
-1meanscostexceedscapacity: the request can never succeed no matter how long it waits. Return a configuration error or another 4xx instead of 429, otherwise the client will retry forever. capacitymust be at least the largest cost (for example, if export costs 50 tokens, capacity is at least 50).- Call it with
EVALSHA; the script cache does not survive restart/failover, and client libraries usually reload it automatically onNOSCRIPT.
The script blocks the whole server while it runs, so keep it short and O(1); a slow script makes every other Redis client wait (Redis single-threaded).
Clocks: pick one source
Section titled “Clocks: pick one source”- Use Redis’s clock (
TIME) instead of each instance’sDate.now(). Clock skew between Pods makeselapsednegative or inflated, refilling the wrong number of tokens. Themath.max(0, ...)above guards against the negative case. - Clock for local in-RAM limiters: use a monotonic clock (
performance.now()orprocess.hrtime.bigint()in Node), not a wall clock that NTP may move backward. - What if Redis goes down? Decide in advance: fail open (allow, preserve availability, add a local limit as a safety net) or fail closed (reject, for sensitive endpoints such as login). Set a short timeout on Redis commands so the rate limiter does not become a source of latency.
How to reject: 429, Retry-After and RateLimit headers
Section titled “How to reject: 429, Retry-After and RateLimit headers”HTTP/1.1 429 Too Many RequestsContent-Type: application/problem+jsonRetry-After: 2RateLimit-Policy: "burst";q=10;w=1RateLimit: "burst";r=0;t=2
{ "type": "https://api.example.com/problems/rate-limited", "title": "Too many requests", "status": 429, "detail": "API key abc has used all 10 requests in the current window."}| Component | Source | What to remember |
|---|---|---|
429 Too Many Requests |
RFC 6585 §4 | The client sent too many requests in a given amount of time. The RFC does not specify how to identify the user or how to count. A 429 response MUST NOT be stored by a cache. |
Retry-After |
RFC 9110 §10.2.3 | The value is an HTTP-date or a non-negative number of seconds (Retry-After: 120). RFC 6585 says a 429 MAY include this header. |
| Body | RFC 9457 | Use application/problem+json with a dedicated rate-limit type so clients don’t have to parse detail. |
RateLimit, RateLimit-Policy |
IETF draft | Not an RFC yet, see below. |
IETF draft status (checked 2026-10-18): draft-ietf-httpapi-ratelimit-headers-11 (2026-05-23) from the HTTPAPI working group is still an Internet-Draft, expiring 2026-11-24. The draft defines two fields:
RateLimit-Policy: the policy, with parametersq(quota),w(window, seconds),qu(quota unit, default requests),pk(partition key).RateLimit: the current state, with parametersr(remaining quota),t(effective window, seconds),pk.
The X-RateLimit-Limit/-Remaining/-Reset headers are popular but not standard; each provider interprets Reset differently (seconds remaining or epoch). If you use the draft, state clearly in the API docs that it follows a draft and may change. When both are present, the draft specifies that Retry-After takes precedence.
A client receiving 429 should wait per Retry-After, add jitter, and not retry indefinitely: see retry, backoff and jitter.
What to count by
Section titled “What to count by”| Key | Pros | Traps |
|---|---|---|
| User id / tenant | Fair per customer, tied to the service plan | Only available after authentication; anonymous endpoints need another key |
| API key / client id | Clear for B2B, easy to sell quota per plan | One customer with many keys → aggregate per tenant |
| IP | Available right at the edge, blocks anonymous floods | NAT: a whole company or mobile network shares one IP, so blocking one person blocks hundreds. IPv6: a subnet is usually a /64, attackers rotate addresses at will; group by prefix. Behind a proxy, take the IP from a header set by a trusted proxy, never trust an X-Forwarded-For sent by the client. |
Rate limiting does not replace authorization. An attacker enumerating IDs in BOLA/IDOR style is only slowed down; data still leaks if permission checks are missing.
Several layers, each with one job
Section titled “Several layers, each with one job”Loading diagram…
- Gateway: flood protection by IP, with generous limits so NAT users are not blocked by mistake.
- Service: quotas per user/API key/tenant, with business context.
- Expensive endpoints: limit the number of requests running concurrently, not just requests per second.
Cost-based limits
Section titled “Cost-based limits”Not every request costs the same. GET /orders/42 and POST /reports/export over a year of data should not both take 1 token.
- Assign a cost to each request type (for example export costs 50 tokens), using
ARGV[3]in the script. - GraphQL: compute cost from query depth and size before executing.
- The RateLimit draft supports this through the
quparameter (a quota unit other than “requests”).
Protecting the login endpoint
Section titled “Protecting the login endpoint”Login needs its own rules because attackers actively evade limits:
- Count failures per account, not only per IP: OWASP notes that with per-IP counting an attacker only has to spread attempts across many addresses.
- Add a per-IP limit to stop password spraying (one IP trying many accounts).
- Lockout can be abused to lock other people’s accounts: use progressively increasing delays instead of a hard lock, and still allow password reset.
- CAPTCHA is only an additional layer of defense; MFA is the primary measure.
- Return the same error message for “wrong password” and “no such account”.
When to use it, when not
Section titled “When to use it, when not”| Use | Don’t use / not enough |
|---|---|
| Public APIs, B2B with quotas per plan | As a substitute for authorization or validation |
| Expensive endpoints (export, search, AI) | As the only overload protection: you also need timeouts, concurrency limits, load shedding |
| Login, OTP, password reset | Internal service-to-service traffic that already has its own backpressure |
| Protecting downstreams with quotas (SMS, payment) | When per-Pod local limits are sold as a global quota |
Benefits
- One customer cannot bring down the experience for everyone.
- Downstream costs (database, metered providers) get a predictable ceiling.
- Slows down brute force and scraping.
Costs
- A shared store adds a round-trip and a dependency on the hot path.
- Blocks legitimate users behind NAT if the per-IP limit is too tight.
- Needs operating: per-plan configuration, dashboards, exceptions for large customers.
Production checklist
Section titled “Production checklist”- Each limit documents its key (user, API key, tenant, IP/prefix), algorithm and parameters (capacity, rate, window).
- Global quotas use a shared store with atomic operations (Lua/
INCR); local limits are only for instance self-protection. - Reject with
429+Retry-After+ problem+json; if you use NGINXlimit_req, changelimit_req_statusfrom the default 503. - Decide in advance between fail open and fail closed when Redis fails, with a short timeout on Redis commands.
- IPs come from a trusted proxy; IPv6 is grouped by prefix; IP limits are wide enough for NAT.
- Login/OTP endpoints have limits per account and per IP, with progressive delays instead of hard locks.
- Metrics: requests blocked per key/route, top keys close to the limit, rate limiter latency; alert when the 429 rate spikes.
- Test with bursts at window boundaries and with many instances running in parallel.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check:
- With a 100/minute fixed-window limit, how many requests can get through in 1 second at most?
- Why is an in-RAM token bucket on 4 Pods not a 100/minute limit for one API key?
- Should a login endpoint count failures per IP or per account, and why do you need both?