Guide · System design
Design a Rate Limiter: Token Bucket, Sliding Window and Redis
14 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- A rate limiter caps how many requests a client (user, API key, IP or tenant) can make in a time window and rejects the excess with HTTP 429 Too Many Requests.
- For most APIs, use a token bucket: it allows short bursts up to the bucket size, holds the long-run rate steady, and stores two numbers per key.
- A sliding window counter (previous window count weighted by overlap, plus current count) gives a strict "N per minute" without the fixed window boundary burst.
- In a distributed limiter, keep state in Redis and do the read, refill and decrement in one atomic Lua script, because separate GET/SET or INCR/EXPIRE calls race.
- Fail open when Redis is down for ordinary APIs, and fail closed for abuse-sensitive endpoints like login and OTP.
- Return 429 with Retry-After; the RateLimit header fields are still an IETF Internet-Draft, not an RFC, as of October 2026.
Picture the IRCTC Tatkal window opening at 10 AM. A few lakh people hit refresh at once, and a handful of scripts hit it a few hundred times a second each. Without a limiter, those scripts eat the capacity meant for everyone else, and your booking service falls over at the exact moment it matters.
"Design a rate limiter" is a favourite interview question because it looks small and isn't: algorithms, Redis, HTTP semantics, distributed consistency and failure handling, all in under an hour. Here is the answer a strong candidate gives, in interview order.
What is a rate limiter and what should it do?
A rate limiter is a component that counts requests per client identity over time and blocks requests once that client exceeds an allowed rate. It protects services from overload, stops one noisy tenant starving the rest, slows brute-force and scraping, and keeps bills predictable.
Don't start drawing. Spend two minutes on requirements, because the right algorithm depends on them. Typical answers from an interviewer:
Functional requirements
- Limit per identity (user, API key, IP or tenant), optionally per route: POST /orders stricter than GET /products.
- Several rules at once, like 10 per second and 10,000 per day for the same key.
- Limits per plan tier, changeable without a deploy.
- Reject with HTTP 429 and tell the client when to retry.
- Allow short bursts (an app firing five calls on screen load) but not sustained abuse.
Non-functional requirements
- Low latency: it runs on every request, so it should add about a millisecond.
- Highly available: a limiter outage must not become an API outage.
- Distributed: limits hold across all gateway instances, not per machine.
- Approximately accurate: allowing 101 instead of 100 during a race is fine for most APIs.
- Observable: metrics on what each rule blocks, so you can tune limits.
Back-of-envelope estimate: how big is this?
Size the limiter itself, not just the product. Assume a public API with 10 million daily active users, a peak of 50,000 requests per second, and one limiter check per rule per request.
| Quantity | Estimate | How we got it |
|---|---|---|
| Limiter checks at peak | 50,000 per second | One check per request, one rule per request |
| With 2 rules per request | 100,000 script calls per second | Per-second rule plus per-day quota |
| Active keys | About 10 million | One bucket per active user |
| State per key | About 100 bytes | Small hash (tokens, timestamp) plus key and Redis overhead, rounded up |
| Total memory | About 1 GB | 10 million x 100 bytes, keys expire when idle |
| Added latency | About 1 ms | One round trip to Redis in the same zone |
Memory is tiny; the ops rate drives the design. A Redis primary executes commands on one main thread and a Lua script costs more than a plain INCR, so 100,000 script calls per second is a reason to shard with Redis Cluster and keep headroom. Benchmark your own hardware with redis-benchmark instead of quoting a magic number.
Where should the rate limiter live?
Put the main limiter at the API gateway, backed by Redis, and add business-aware limits inside the services that need them. Each layer sees different information, so real systems use more than one.
- Client side: polite SDKs back off on 429, but you can never trust it; anyone can call your API with curl.
- Edge or CDN: cheap per-IP limits that stop floods early. It knows the IP, not the user or plan.
- API gateway: the usual home. It sees every request after auth, knows the API key and route, and keeps the logic out of each service.
- Middleware in a service: knows business context, like "3 OTP sends per phone number per 10 minutes".
- Service mesh sidecar: Envoy-style proxies apply a local token bucket and call a global rate limit service, covering service-to-service traffic too.
Where the rate limiter sits
Drag to exploreThe gateway checks every request against Redis before routing it. Rules come from config so limits change without a deploy, and every 429 is counted so you can see which rule is firing.
Diagram connections:
- Mobile and web clients to Load balancer (HTTPS)
- Load balancer to API gateway + limiter
- Rules config to API gateway + limiter (limits per tier)
- API gateway + limiter to Redis cluster (atomic check (Lua))
- API gateway + limiter to Orders service (allowed)
- API gateway + limiter to Catalog service (allowed)
- API gateway + limiter to Metrics and alerts (429 counts)
- Mobile and web clients: Send API key or JWT
- API gateway + limiter: Identifies caller, runs the limiter check
- Rules config: Limits per tier and route, cached in memory
- Redis cluster: Bucket state per key, sharded by key
- Metrics and alerts: Allowed vs 429 per rule
Rate limiting algorithms explained
Know five algorithms: how each works and where it breaks. You'll be asked to compare two and pick one with a reason.
Fixed window counter
A fixed window counter keeps one counter per key per clock-aligned window (say, per calendar minute) and rejects requests once it passes the limit. One INCR and one integer, which is why everyone starts here.
The flaw is the boundary burst. With 100 per minute, a client sends 100 requests at 12:00:59 (allowed) and 100 more at 12:01:00 (allowed, new window). That is 200 requests in about two seconds. Worst case, a fixed window lets through 2x the limit within one window length.
Sliding window log
A sliding window log stores the timestamp of every accepted request, drops timestamps older than the window, and counts the rest. In Redis: a sorted set with ZREMRANGEBYSCORE, ZCARD and ZADD. It is exact. The cost is memory: 1,000 requests per hour means up to 1,000 entries per key. Great for low limits like 5 password resets per hour, bad for high-volume APIs.
Sliding window counter
A sliding window counter keeps fixed window counters but estimates the rolling rate by weighting the previous window by how much of it still overlaps the sliding window. The formula is: estimated count = previous window count x ((window length - time elapsed in current window) / window length) + current window count.
Example with 100 per minute: the previous minute had 80 requests, the current one has 30, and we are 15 seconds in. Estimate = 80 x (45 / 60) + 30 = 90, so allow. It assumes the previous window's traffic was spread evenly. Cloudflare reported that across 400 million requests from 270,000 sources, this wrongly allowed or limited only 0.003% of requests. Two integers per key for near-exact behaviour is a great trade.
Token bucket
A token bucket gives each key a bucket that holds up to a capacity of tokens, refills at a fixed rate, and charges one token per request; a request with no token available is rejected. Capacity controls the burst you allow, refill rate controls the sustained rate. A bucket with capacity 20 and refill 5 per second lets a client fire 20 requests instantly, then settle to 5 per second.
No timer is needed. Store the token count and last refill time, and on each request compute tokens = min(capacity, tokens + elapsed x rate). Stripe has written that its API uses token bucket limiters in Redis; it is the usual default because bursts are normal client behaviour.
Leaky bucket
A leaky bucket puts requests into a fixed-size queue that drains at a constant rate and drops new requests when full. Output is perfectly smooth, ideal in front of an SMS vendor with a hard per-second cap. The cost is latency, since requests wait in the queue. Used as a meter instead of a queue it behaves like a token bucket; GCRA (generic cell rate algorithm) is that variant, storing one timestamp per key.
| Algorithm | Memory per key | Burst handling | Accuracy | Use it for |
|---|---|---|---|---|
| Fixed window counter | 1 counter | Up to 2x limit at window edges | Low at boundaries | Daily or monthly quotas, rough limits |
| Sliding window log | 1 entry per request in window | No bursts beyond limit | Exact | Low limits on sensitive actions (login, OTP) |
| Sliding window counter | 2 counters | Smoothed, small over-admission possible | Very close to exact | Strict "N per minute" API limits |
| Token bucket | Tokens + timestamp | Allows bursts up to capacity, then steady rate | Exact for its model | Most public APIs, per-user and per-key limits |
| Leaky bucket (queue) | Queue up to its size | Absorbs bursts, emits a constant rate | Exact output rate | Protecting fragile downstreams, shaping traffic |
How do you build a distributed rate limiter with Redis?
Keep limiter state in a shared Redis cluster and run each check as one atomic Lua script, so every gateway sees the same counts. Per-instance memory fails: with 20 gateway pods, 100 per minute quietly becomes up to 2,000.
Why INCR then EXPIRE in two calls is racy
The classic first attempt at a fixed window looks like this. It has a bug that only shows up in production.
// Racy: two separate round trips
const count = await redis.incr(key); // call 1
if (count === 1) {
await redis.expire(key, 60); // call 2
}
if (count > limit) return reject429();If the pod dies or the network drops between call 1 and call 2, the key has no TTL. It never resets, and that user stays blocked forever once over the limit; the Redis docs flag this exact race. Token bucket in app code is worse: two pods both read "1 token left", both allow, both write 0. That check-then-act race admits more than the limit.
The fix is to move the read-modify-write into Redis. Redis runs a Lua script atomically, so no other command can touch the key halfway through. MULTI/EXEC fixes INCR plus EXPIRE but can't branch on values it reads, so token bucket needs a script.
Token bucket as an atomic Lua script
-- KEYS[1] = bucket key, e.g. rl:{key:ak_123}:post_orders
-- ARGV[1] = capacity (max tokens)
-- ARGV[2] = refill rate (tokens per second)
-- ARGV[3] = cost of this request (usually 1)
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
-- Use the Redis server clock so every gateway agrees on "now"
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000) -- ms
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil or ts == nil then
tokens = capacity
ts = now
end
-- Lazy refill for the time that passed, capped at capacity
local elapsed = math.max(0, now - ts)
tokens = math.min(capacity, tokens + elapsed * rate / 1000)
local allowed = 0
local retry_ms = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
retry_ms = math.ceil((cost - tokens) * 1000 / rate)
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- An idle bucket is full again after capacity/rate seconds; drop it then
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity * 1000 / rate) + 1000)
return { allowed, math.floor(tokens), retry_ms }Details interviewers notice: the key arrives in KEYS, which Redis requires for scripts to work in a cluster. TIME inside a script that then writes is safe because Redis 5 made effects replication the default (Redis 7 dropped the old verbatim mode). The TTL frees idle buckets, and losing a full bucket costs nothing since a missing key starts full. Load the script with SCRIPT LOAD and call it with EVALSHA.
Token bucket check, step by step
Drag to exploreThe gateway sends one EVALSHA call. Inside Redis the script refills lazily from the elapsed time, takes a token if one is there, and writes state back, all as one atomic step. The gateway only routes or rejects.
Diagram connections:
- Incoming request to Limiter middleware
- Limiter middleware to Lua: refill, then take (EVALSHA key, cap, rate, 1)
- Lua: refill, then take to Redis hash: tokens, ts (HMGET, then HSET + PEXPIRE)
- Limiter middleware to Orders service (allowed = 1)
- Limiter middleware to 429 + Retry-After (allowed = 0)
- Incoming request: API key ak_123, POST /orders
- Limiter middleware: Builds key, looks up tier limits
- Lua: refill, then take: tokens = min(cap, tokens + elapsed x rate)
- Redis hash: tokens, ts: TTL = time to refill fully
- Orders service: Gets the request with RateLimit headers
- 429 + Retry-After: Retry-After from retry_ms
Key design: per user, IP, API key and route
The key decides what you are limiting. Use rl:{identity}:rule, where the braces are a Redis Cluster hash tag that keeps one identity's keys on one shard.
- Per API key (rl:{key:ak_123}:global): fairest for public APIs and maps directly to plan tiers.
- Per user and route (rl:{user:42}:post_orders): expensive writes get tighter limits than cheap reads.
- Per IP (rl:{ip:203.0.113.7}:login): the only option before auth. Carrier-grade NAT and campus networks put thousands of users behind one IP, so keep IP limits loose.
- Per tenant (rl:{tenant:acme}:global): an umbrella so one customer with 500 API keys can't take shared capacity.
What should the API return when a client is rate limited?
Return HTTP 429 Too Many Requests with a Retry-After header saying how many seconds to wait, plus a short JSON body explaining which limit was hit. RFC 6585 defines 429 and says the response may include Retry-After; a well-behaved client waits that long instead of hammering you.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 3
RateLimit-Policy: "burst";q=20;w=4, "daily";q=10000;w=86400
RateLimit: "burst";r=0;t=3
{"error":"rate_limited","rule":"burst","retry_after_seconds":3}RateLimit and RateLimit-Policy come from the IETF HTTPAPI working group draft draft-ietf-httpapi-ratelimit-headers. As of October 2026 the latest is draft 11 (May 2026), still an Internet-Draft, not an RFC, so the syntax may change. RateLimit-Policy gives each policy a quota (q) and window in seconds (w); RateLimit reports remaining quota (r) and seconds until reset (t). If Retry-After is present, it takes precedence.
Older conventions are everywhere: many APIs, GitHub among them, send X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and earlier draft versions used separate RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset fields. In the interview, "429 plus Retry-After, and remaining-quota headers on every response" earns the credit.
Fail open or fail closed when Redis is down?
For most APIs, fail open: if Redis doesn't answer within a tight timeout, allow the request and log it. Stripe describes catching limiter errors so a bug or Redis outage never blocks real traffic. A limiter protects availability; it shouldn't be the reason you lose it.
Fail closed where abuse costs more than a short outage: login, OTP, password reset, paid SMS. If knocking out Redis unlocks unlimited OTP guesses, failing open handed the attacker the door. Decide per endpoint.
Give the limiter a strict timeout
Around 5 to 20 ms. A slow limiter adds latency to every request.
Add a circuit breaker
After repeated failures, skip Redis briefly instead of paying the timeout each time.
Fall back to a local limiter
Each gateway enforces global limit divided by instance count. Approximate, but far better than nothing.
Alert on fallback mode
Otherwise you find out about the scraping problem from your monthly bill.
How do you rate limit across multiple regions?
Check against a Redis cluster in the same region and sync usage between regions asynchronously, accepting a small, bounded over-admission. A single global Redis adds a cross-region round trip, often tens to hundreds of milliseconds, to every request.
- Static split: each region gets a share of the global limit (60% Mumbai, 40% Frankfurt). Simple, but unfair when traffic shifts.
- Async aggregation: regions ship usage deltas to an aggregator every second or so, which rebalances shares. Over-admission is bounded by about one sync interval of traffic.
- Local plus global: a coarse in-memory token bucket per instance absorbs spikes, and only survivors hit Redis. Envoy documents this two-stage pattern.
Multi-region rate limiting
Drag to exploreEach region answers limiter checks from its own Redis, so the check stays fast. An aggregator sums usage per key across regions and rebalances each region's share, which means limits are slightly loose for about one sync interval.
Diagram connections:
- Users in India to Mumbai gateways (nearest region)
- Users in Europe to Frankfurt gateways (nearest region)
- Mumbai gateways to Mumbai Redis (check, about 1 ms)
- Frankfurt gateways to Frankfurt Redis (check, about 1 ms)
- Mumbai Redis to Global usage aggregator (usage deltas every 1 s)
- Frankfurt Redis to Global usage aggregator (usage deltas every 1 s)
- Global usage aggregator to Regional share table (rebalanced shares)
- Mumbai gateways: Local in-memory bucket as first pass
- Frankfurt gateways: Local in-memory bucket as first pass
- Mumbai Redis: Regional bucket state
- Frankfurt Redis: Regional bucket state
- Global usage aggregator: Sums usage per key across regions
- Regional share table: Each region's slice of every global limit
Hot keys, clock issues and other failure modes
Hot keys
One giant customer, or one shared IP during a Big Billion Days sale, can aim tens of thousands of checks per second at one key, and in Redis Cluster one key lives on one shard. Fixes, cheapest first: cache "blocked until" in gateway memory so rejected clients stop hitting Redis; take tokens in batches (grab 10 per call, spend locally); or split the key into N sub-buckets with limit / N each.
Clock issues
If each gateway uses its own clock, a pod 300 ms ahead refills faster than its neighbours, and an NTP correction backwards gives negative elapsed time. Reading TIME from Redis, as the script does, gives one clock per key. Clamp elapsed at zero anyway.
Other things that go wrong
- Retry storms: clients retrying 429s instantly make overload worse. Send Retry-After; SDKs should back off with jitter.
- Checking too late: run the limiter before the expensive database or model call.
- Untested rules: dark launch new limits in log-only mode first, as Stripe recommends.
Extensions: tiers, quotas and concurrency limits
A rate limit (10 per second) protects infrastructure from bursts. A quota (10,000 per day or month) is a billing decision. Quotas can use a cheap fixed window with a daily key, since boundary bursts don't matter there, but need durable accounting in a database if they drive invoices.
Store tier limits as data (free: 60 per minute, pro: 600) in config the gateway caches, so limits change without a deploy. Stripe also runs a concurrent requests limiter capping in-flight requests per user, which protects slow endpoints better than a per-second count, plus load shedders that drop low-priority traffic when the fleet is hot. Good material when the interviewer asks "what else?".
Common mistakes in rate limiter interviews
- Jumping to Redis before asking what is being limited and why.
- Counting in each server's memory and calling it distributed.
- Picking a fixed window without mentioning the boundary burst.
- Limiting only by IP, punishing everyone behind one carrier NAT.
- Forgetting the limiter itself can fail.
- Returning 500 or 403 instead of 429.
Questions people ask
- Which rate limiting algorithm is best?
- For most APIs, token bucket is the best default: it allows short legitimate bursts, enforces a steady average rate, and stores two values per key. Use a sliding window counter for a strict per-minute contract, a sliding window log for small limits that must be exact, and a leaky bucket queue when a downstream needs a smooth rate.
- What is the difference between token bucket and leaky bucket?
- A token bucket allows bursts up to its capacity, then limits to the refill rate, while a leaky bucket queues requests and releases them at a constant rate. Token bucket rejects excess immediately and keeps latency low. Leaky bucket smooths output perfectly but makes requests wait, which suits shaping traffic to fragile downstreams.
- How does a sliding window rate limiter work?
- A sliding window limiter counts requests in the last N seconds instead of in clock-aligned windows. The log version stores every timestamp and is exact. The counter version estimates previous window count times the fraction still in range, plus the current count, using two integers per key.
- Why use a Lua script for rate limiting in Redis?
- A Lua script runs atomically in Redis, so read, refill, decrement and write happen as one step no other client can interleave with. With separate commands, two servers can read the same token count and both allow, and a crash between INCR and EXPIRE leaves a key with no TTL.
- What HTTP status code should a rate limiter return?
- A rate limiter should return HTTP 429 Too Many Requests, defined in RFC 6585, with a Retry-After header saying how many seconds to wait. Many APIs also send remaining-quota headers, either X-RateLimit-* fields or RateLimit and RateLimit-Policy from the IETF draft.
- Should a rate limiter fail open or fail closed?
- Most rate limiters should fail open, allowing requests when the store is unreachable, so a Redis outage does not become an API outage. Login, OTP verification and paid SMS endpoints should fail closed or use a strict local limit, because unlimited access there costs more than briefly rejecting users.
Next, read up on caching: the same Redis cluster and hot key problems show up there. Then try this question on a board with a timer running, which is when gaps in your story show.
Sources and further reading
- IETF: RateLimit header fields for HTTP (draft-ietf-httpapi-ratelimit-headers)
- RFC 6585: Additional HTTP Status Codes (429 Too Many Requests)
- Stripe: Scaling your API with rate limiters
- Cloudflare: How we built rate limiting capable of scaling to millions of domains
- Redis docs: Scripting with Lua
- Redis docs: INCR and the rate limiter pattern
- Envoy docs: Global rate limiting