Practical Design Problems
Design a Rate Limiter
Token bucket, leaky bucket, fixed and sliding windows, where to apply limits, 429 with Retry-After, and the new RateLimit headers.
On this page
- Requirements
- Where does it live?
- The algorithms
- A token bucket you can run
- Making it correct across many servers
- Keys, rules and configuration
- What the client sees
- When the limiter itself fails
- Scenario: legitimate users sharing one IP get blocked
- Scaling and observability
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: a rate limiter decides, for each caller, whether a request may go through right now. The algorithm is the easy part. The interview is about where it runs, how many servers share and update the counts safely, what you send back to clients, and what happens when the limiter itself breaks.
You met rate limits as a client in Debugging, Lesson 09 (the surprise 429 error). Now you will design the part that sends it.
Words you need first
- Rate limiter is a part of a system that counts the requests from each caller and rejects the ones above an allowed number in a period of time. Think of a door guard who lets in 100 people a minute and asks the rest to wait.
- Request is one message from a client (a browser or app) to a server asking for something. HTTP 429 Too Many Requests is the status code (a three-digit number in the answer) that means "you sent too many requests, slow down".
- API key is a secret string that identifies which app or customer is calling an API.
- IP address is the number that identifies a device on a network, such as
203.0.113.7. - Redis is a fast data store that keeps data in memory. Many servers can share it. It can count and expire values quickly.
- Burst is many requests sent in a very short time.
- Throttling is another word for slowing callers down. A limiter can throttle (delay) or reject. This lesson mostly rejects.
1. Requirements
Functional (what the system does)
- Limit requests for each key. A key is the label that says whose requests are counted together. It can be a user ID, an API key, an IP address, or a mix. You can also limit by route. For example, login attempts get a stricter limit than reads.
- Support more than one rule. For example: 100 requests per minute and 5,000 per day, with short bursts allowed.
- Reject extra requests with HTTP 429 and tell the client when to try again.
- Let you set limits for each plan or endpoint, and change them without a new release.
Non-functional (how well it must work)
- Very low delay. It runs on every request. Aim for under 1 to 2 ms added.
- Correct across many servers. If you have 10 instances, each must not allow the full limit.
- Highly available. The limiter must not be the reason the site goes down.
- Low memory use, even with millions of keys.
- High availability means the limiter keeps working when one of its parts breaks. Delay (also called latency) is the extra time one request waits.
It helps to tell similar ideas apart:
| Concept | What it protects against | Example |
|---|---|---|
| Rate limiting | One caller sending too fast | 100 requests per minute per API key |
| Quota | Total use over a long time (for billing) | 1 million calls per month |
| Load shedding | The system itself being overloaded, whoever the caller is | Drop low-priority traffic when CPU is above 90% |
| Concurrency limit | Too many requests running at the same time | 10 uploads at once per user |
In one line: a rate limit counts requests per time period. A concurrency limit counts requests running right now. A quota counts total use over a long period.
2. Where does it live?
| Location | Pros | Cons |
|---|---|---|
| Client-side | Polite, nothing more | Cannot be trusted: attackers ignore it |
API gateway / edge / CDN / WAF (an API gateway is one front door that all API calls pass through; the edge is servers close to users; a CDN is a network of such servers; a WAF, web application firewall, blocks bad requests; examples: AWS API Gateway throttling, Cloudflare, nginx limit_req) | Stops abuse before it reaches your servers; no code to write | Less flexible rules (business logic, per-user plans); depends on the vendor |
| Middleware in your app (code that runs on each request) | Full control (per user, per plan, per route) | The request has already used app resources; instances need shared state |
| Dedicated limiter service (a separate program that only answers "allowed or not?"; gateways or apps call it) | One implementation for many services; rules in one place | An extra network call and a critical dependency |
Most teams use layers (several limiters, one behind the other). At the edge, they set rough IP limits to stop floods. In the app or gateway, they set exact limits for each user and endpoint, using a shared store like Redis.
3. The algorithms
Fixed window counter
Count the requests for each key in the current fixed time window (for example, each calendar minute). Reject when the count goes over the limit.
key = userId + ":" + floor(now / 60s)count = INCR key ; set expiry 60s on first incrementif count > 100 → reject- Pro: very simple and cheap. One counter for each key for each window.
- Con: boundary burst. A client can send 100 requests at 0:59 and another 100 at 1:00. Each minute stays within the limit, but that is 200 requests in about one second, double the rate you wanted.
Sliding window log
Store a timestamp for each request. On each new request, remove timestamps older than the window, then count what is left.
- Pro: exact (the window really moves with time, so "no more than N in any 60 seconds" is always true).
- Con: it uses a lot of memory (one entry for each request for each key) and more work for each request. It is fine for small limits.
Sliding window counter (the practical middle way)
Keep counters for the current window and the previous window. Count part of the previous window, based on how much of it still overlaps the sliding window. For example, 30 seconds into the current minute, half of the previous minute still overlaps, so count half of its requests:
estimated = prevCount × (1 − elapsedFractionOfCurrentWindow) + currentCountreject if estimated ≥ limit- Pro: two numbers for each key. It smooths the boundary burst and is accurate in practice.
- Con: it is an estimate. It assumes that the requests in the previous window were spread out evenly.
Token bucket (the most common)
Picture a bucket that holds up to capacity tokens. New tokens drop in at a steady rate. Each request takes one token. If the bucket is empty, the request is rejected.
capacity = 100 tokens, refill = 100 tokens / minute (≈ 1.67 per second)Idle for a while → bucket fills up → a client can burst up to 100 at once, then is limited to the refill rate.- Pro: it allows bursts (good for real clients) and still keeps the average rate. It stores only two values for each key (the tokens and the last refill time). Stripe has written about running its rate limiters as token buckets on Redis, and AWS API Gateway describes its throttling as a token bucket.
- Con: it has two settings to tune. You must choose the burst size on purpose.
Leaky bucket
Requests go into a queue (a waiting line where the first request in is the first one out). That queue is the bucket. It empties at a constant rate. If the queue is full, new requests are dropped.
- Pro: it gives a smooth, steady flow out. This protects a weak service behind it (nginx
limit_reqis based on this idea). - Con: a burst fills the queue and makes queued requests wait. If the queue is long, old requests can block newer ones.
Comparison
| Algorithm | Memory per key | Burst handling | Accuracy | Best for |
|---|---|---|---|---|
| Fixed window | 1 counter | Allows 2x at boundary | Low | Simple, rough limits |
| Sliding window log | Many timestamps | Exact | Exact | Low limits that need to be exact |
| Sliding window counter | 2 counters | Smooth | High (estimate) | Normal per-minute limits |
| Token bucket | 2 values | Controlled bursts | High | APIs that accept bursts |
| Leaky bucket | Queue | None (smoothed) | High | Protecting a service that handles a constant rate |
4. A token bucket you can run
First, here is the logic in plain JavaScript (one process), so you can understand it and test it:
function makeTokenBucket({ capacity, refillPerSec }) { const buckets = new Map(); // key -> { tokens, last } return function allow(key, now = Date.now(), cost = 1) { const b = buckets.get(key) ?? { tokens: capacity, last: now }; const elapsed = (now - b.last) / 1000; b.tokens = Math.min(capacity, b.tokens + elapsed * refillPerSec); // refill lazily on access b.last = now; const allowed = b.tokens >= cost; if (allowed) b.tokens -= cost; buckets.set(key, b); return { allowed, remaining: Math.floor(b.tokens), retryAfterSec: allowed ? 0 : Math.ceil((cost - b.tokens) / refillPerSec) }; };}const allow = makeTokenBucket({ capacity: 5, refillPerSec: 1 });let t = 0;const results = [];for (let i = 0; i < 7; i++) results.push(allow("u1", t).allowed); // 7 requests at the same instantconsole.log(results.join(",")); // true,true,true,true,true,false,falseconsole.log(allow("u1", t + 2000).allowed); // true (2 tokens refilled after 2 s)console.log(allow("u1", t + 2000).remaining); // 0 (this call used the second refilled token)Notice the lazy refill: tokens are added only when a request comes in, not by a timer. The code works out how many tokens were added from the time that has passed since the last request. This is why millions of keys are cheap.
5. Making it correct across many servers
Ten API instances need one shared view. So the counters live in a central, fast store: Redis. The danger is a race (also called a race condition: two things happen at the same time and the result depends on which one is faster, so it can be wrong). Two instances read the same count, both allow the request, both write, and the limit is passed. The check and the update must be atomic, which means they happen as one step that nothing can interrupt.
Fixed window with Redis (simple, atomic enough)
const key = `rl:${userId}:${Math.floor(Date.now() / 60_000)}`;const count = await redis.incr(key); // atomicif (count === 1) await redis.expire(key, 60); // ⚠ a crash between INCR and EXPIRE leaves a key that never expiresif (count > 100) return reject();The INCR and EXPIRE pair is not atomic. It is better to use one atomic step: SET key 0 EX 60 NX and then INCR, or a Lua script.
Token bucket with a Lua script (atomic)
Lua is a small scripting language. Redis can run a Lua script on the server, and it runs the whole script atomically. Nothing else runs in the middle, so reading and then updating a value is safe from races.
-- KEYS[1] = bucket key; ARGV = capacity, refillPerSec, nowMs, costlocal capacity = tonumber(ARGV[1])local rate = tonumber(ARGV[2])local now = tonumber(ARGV[3])local cost = tonumber(ARGV[4])local data = redis.call("HMGET", KEYS[1], "tokens", "ts")local tokens = tonumber(data[1])local ts = tonumber(data[2])if tokens == nil then tokens = capacity; ts = now endlocal elapsed = math.max(0, now - ts) / 1000tokens = math.min(capacity, tokens + elapsed * rate)local allowed = 0if tokens >= cost then tokens = tokens - cost; allowed = 1 endredis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)redis.call("PEXPIRE", KEYS[1], math.ceil(capacity / rate * 1000) * 2) -- idle buckets disappearreturn { allowed, math.floor(tokens) }// Express middleware (Express is a Node.js web framework; ioredis is a Node.js Redis client)const sha = await redis.script("LOAD", luaSource);export const rateLimit = (capacity: number, perSec: number) => async (req, res, next) => { const key = `rl:${req.user?.id ?? req.ip}:${req.route?.path ?? "global"}`; try { const [allowed, remaining] = (await redis.evalsha(sha, 1, key, capacity, perSec, Date.now(), 1)) as [number, number]; res.set({ "X-RateLimit-Limit": String(capacity), "X-RateLimit-Remaining": String(remaining) }); if (!allowed) { res.set("Retry-After", String(Math.ceil(1 / perSec))); return res.status(429).type("application/problem+json").json({ type: "https://api.example.com/errors/rate-limited", title: "Too many requests", status: 429 }); } next(); } catch { next(); // fail open: see "When the limiter itself fails" }};(If the clocks on your app servers may not match, use the time from Redis (redis.call("TIME")) instead of each server's own clock.)
Other multi-server options
- A local limiter in each instance with the limit divided (limit ÷ number of instances). There is no network call. But it is not accurate when load is uneven or when the number of instances changes.
- Approximate, eventually consistent sync (the copies of the data may differ for a short time, but they agree later). Each instance counts on its own and sometimes sends the counts to Redis. It has very low delay and allows a small overshoot. It is good at huge scale, when you do not need exact numbers.
- Sticky routing by key. You send each user ID to one instance, so that instance owns the counter for that key. This breaks easily when instances change.
- Redis Cluster. Keys are split over servers by hash slot (Redis Cluster has 16,384 slots, and each key belongs to one slot, chosen by a hash of the key). A Lua script must use keys in one slot. If a script needs several keys, use hash tags like
{user42}. Add replicas for availability. Know that after a failover you can lose a few recent counts.
6. Keys, rules and configuration
- Choose the key to match the threat.
- Use an API key or user ID for logged-in traffic. It is fair and exact.
- Use the IP for anonymous traffic and for credential stuffing (trying stolen username and password pairs on many sites), but be careful (see below).
- Use IP + route for login and OTP (one-time password, a short code sent to the user) endpoints.
- Use a global key to protect a weak dependency, such as an SMS provider.
- Rules as data. Keep them in config or a database (
plan-free: 100/min, burst 100;login-per-ip: 10/min). Cache them locally and reload them while running. When a request has more than one rule, check all of them. Reject if any rule fails. - Cost-based limits. An expensive endpoint uses more tokens (
cost = 10). - Tiered limits. Free and paid plans get different limits. Internal services are exempt or get higher limits.
7. What the client sees
HTTP/1.1 429 Too Many RequestsRetry-After: 18X-RateLimit-Limit: 100X-RateLimit-Remaining: 0X-RateLimit-Reset: 18Content-Type: application/problem+json{ "type": "https://api.example.com/errors/rate-limited", "title": "Too many requests", "status": 429 }- Use status
429(not403). Add aRetry-Afterheader, which tells the client how long to wait (a number of seconds, or an HTTP date). The HTTP specification defines it.429is defined in RFC 6585.403 Forbiddenmeans "you are not allowed at all", so it is the wrong signal here. - Rate-limit headers let clients slow down before they hit the wall. A header is a named line of extra information sent with an HTTP request or answer. Today most APIs use the unofficial names
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset. Their exact meaning differs between APIs (for example,Resetcan be seconds left or a clock time). An IETF draft (a proposal, not yet final) defines two new fields,RateLimit-Policy(the rules) andRateLimit(what is left now). Because it is still a draft, say "emerging" in an interview. - Add an error body that a program can read (
application/problem+jsonis a standard JSON format for HTTP errors), and a request ID (a unique label for this request, useful in logs).
What the client should do:
- Respect
Retry-After. - Retry with exponential backoff with jitter. This means waiting longer after each failure, plus a small random extra time, so clients do not all retry together.
- Do not retry non-idempotent requests blindly. A request is idempotent if sending it twice has the same effect as sending it once (reading a page, or setting a value). A non-idempotent request, such as "charge the card", can do harm if it runs twice.
- Show a friendly message ("Try again in 18 seconds").
- Spread out background work.
- Remove duplicate requests, which means sending one request where several identical ones were about to go out (Debugging, Lesson 05).
8. When the limiter itself fails
What should happen if Redis is down or slow?
| Choice | Behaviour | Use when |
|---|---|---|
| Fail open | Allow the request (no limit while the limiter is down). Like a door that unlocks when the power fails | Most APIs: staying available matters more than strict limits, and you have other protection |
| Fail closed | Reject the request. Like a door that stays locked when the power fails | Security-sensitive endpoints: login, OTP, password reset, where unlimited tries allow brute force (trying many guesses until one works) |
Choose this for each rule. Set a short timeout on the limiter call (a few ms). Raise a loud alert. Think about a fallback limiter in local memory with careful limits. Then you lose some quality, but not all protection.
9. Scenario: legitimate users sharing one IP get blocked
Hundreds of employees behind a company NAT, or a mobile carrier's CGNAT, share one public IP. (NAT, network address translation, lets many devices share one public address. CGNAT is the same idea at a large scale, run by a mobile or internet provider.) A limit for each IP treats them all as one abuser.
Fixes:
- Do not use IP as the only key for logged-in traffic. Limit by user ID or API key. Keep IP limits only as a wide outer layer.
- Log the user in first, then apply user-level rules. Give logged-in users higher IP-level limits.
- Give higher limits or an allow-list to known company ranges and partners.
- Combine signals (IP + device or cookie fingerprint + account; a fingerprint is a value built from traits of the device or browser) to find anonymous abuse. Use challenges (a CAPTCHA is a test that a human can pass and a program cannot; proof-of-work makes the client do a small calculation) instead of hard blocks for suspicious traffic.
- Get the client IP right. A proxy or load balancer (a server that spreads requests over many servers) sits in front of your app, so the connection comes from the proxy, not the user. Read the user's address from the
X-Forwarded-Forheader only when it comes from trusted proxies. If not, attackers fake the header to avoid limits or to blame others. - Watch the 429 rate for each key type, so you notice innocent users getting blocked.
10. Scaling and observability
- Throughput: 100,000 requests per second means 100,000 limiter checks per second. One Redis node can do roughly 100,000 or more simple operations per second. Use a cluster (many Redis servers that share the keys) and keep each check to one round trip (one network trip to Redis and back, here one Lua call). Or reduce the load with local pre-filters.
- Delay budget: keep the limiter in the same availability zone (an isolated location inside a cloud region, made of one or more data centres), reuse connections, and use short timeouts.
- Memory: two fields for each key, with a TTL (time to live: the time after which Redis deletes the key). So only active keys use memory. A key like this takes very roughly 100 to 200 bytes in Redis, so 10 million active keys ≈ 1 to 2 GB. Measure with your real key names.
- Hot keys: a hot key is a key with far more traffic than the others. One abusive key sends all its traffic to one shard (one Redis server that holds part of the keys). Blocking at the edge stops it earlier.
- Metrics: allowed vs rejected for each rule, limiter delay and errors, and the most limited keys. Alert on sudden jumps. Write the limiter decision in the logs with the request ID.
- Testing: unit-test the algorithms with a clock you control. Load-test the Redis path. Chaos-test (break things on purpose) to see how the limiter behaves when it fails.
Common mistakes
- Limiting only on the client side.
- Fixed windows without knowing about boundary bursts.
- Reading and then updating across servers in a non-atomic way (a race that goes over the limit).
INCRwithout a reliableEXPIRE, which leaks keys.- Limiting only by IP, which punishes shared networks.
- Trusting
X-Forwarded-Forfrom the open internet. - No decision about fail-open vs fail-closed.
- No
Retry-After, so clients retry at once and make things worse. - Using
403instead of429.
Interview questions
Token bucket vs sliding window: what are the trade-offs?
A token bucket stores two values for each key. It allows bursts up to its capacity and keeps an average refill rate. It is cheap and easy to understand for APIs. A sliding window is stricter: it says "no more than N in any window". It is exact with a log and approximate with the two-counter version. It avoids boundary bursts, but it does not model a burst allowance as naturally. I pick a token bucket when bursts are fine. I pick a sliding window when the limit must hold over any time span.
How do you rate-limit across 10 server instances?
Keep the counters in a shared, low-delay store such as Redis. Do the check and the update atomically (a Lua script or atomic commands), so instances at the same time cannot go over the limit. At very large scale, accept approximate limits: count locally and sync now and then. Use the time from Redis, or accept small clock differences.
Which headers do you return to clients?
429 Too Many Requests with Retry-After, plus headers with limit information (the common X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset). The IETF RateLimit-Policy and RateLimit fields are still a draft. So they are emerging, not final.
Scenario: legitimate users who share one IP address behind a NAT get blocked. Fix it.
Stop using the IP as the main key for logged-in users. Limit by user or API key instead. Keep a wide IP-level outer limit. Allow-list known company ranges. Combine signals and use challenges for anonymous traffic. Read the real client IP only from trusted proxy headers. Watch for innocent users being blocked.
How should the frontend respond to a 429?
Respect Retry-After. Back off with growing waits plus jitter. Stop automatic retries of non-idempotent requests. Show a clear message that says when to try again. Pause polling. Remove repeated calls. Avoid retries that happen at the same time across tabs or users.
Fail open or fail closed when Redis is down?
It depends on the endpoint. Most APIs fail open, so a limiter problem does not become a total outage. They may add a careful local fallback. Security-critical endpoints such as login or OTP fail closed, to stop unlimited brute-force tries. You set this for each rule and you monitor it.
What is the boundary problem in fixed-window counters?
A client can use the whole limit at the end of one window and again at the start of the next. So it can send up to twice the limit in a short time around the boundary. Sliding windows or token buckets avoid this.
Where would you place the limiter in the architecture?
In layers. At the edge or API gateway, I put rough IP and flood protection before traffic reaches the app. In a gateway or middleware, I put exact per-user or per-endpoint limits, with Redis for shared state. For many services, a central limiter service or a sidecar (a helper process that runs next to each service) keeps the rules the same everywhere.
Key takeaways
- A limiter is key + rule + algorithm + shared state + response.
- Token bucket (bursts + average rate) is the usual default. Sliding window counter avoids boundary bursts. Fixed window is simple but bursty. Leaky bucket smooths the output.
- Make the check atomic in Redis (Lua), refill lazily, and expire idle keys.
- Return 429 with
Retry-Afterand rate-limit headers. Clients should back off with jitter. - Decide fail open vs fail closed for each endpoint. Do not rely on IP alone, and do not trust headers that can be faked.
What's next
Lesson 03: Design Pagination. This is another API design that looks easy until the data is large and keeps changing: offset versus cursor (keyset) pagination.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- What Makes a Good API: Resources, URLs and the Contract · API Design
- Idempotency Keys, Retries, Webhooks and Rate Limits · API Design
- Clean Code for Backends: Naming, Small Functions and Boundaries · Backend in Depth
Related series