Concept 3: Rate Limiting
System Design Bible — File 03 of 10 Difficulty: ★★☆☆☆ Prerequisites: Files 01–02, plus the concepts unpacked in Section 0 below.
Table of Contents#
- Prerequisites — What You Must Understand First
- Architectural Definition & "The Why"
- The Recursive Sub-Concept Tree
- The Algorithms — Exhaustive Breakdown
- Distributed Rate Limiting
- Where to Enforce & How to Respond
- Pros, Cons & Trade-offs
- Interview Diagnostic Framework (Symptom → Solution)
- Real Implementations & Product Landscape
- Real-World Engineering Scenarios
- Interview Gotchas & Failure Modes
- Whiteboard Cheat Sheet
0Prerequisites — What You Must Understand First#
Before rate limiting makes sense, you need four ideas solid. Each is short but load-bearing; skim-reading them is why the rest feels abstract.
0.1What "a request" actually costs the server#
When a client sends an HTTP request, the server doesn't just "answer it." It spends real, finite resources on it: a thread (or async task) is occupied for the duration; a socket / file descriptor is held open; CPU cycles parse, authenticate, and process it; memory buffers the request and response; and often a database connection is checked out from a small pool (typical pools are 10–100 connections total). Every server has a hard ceiling on each of these — you might have 200 worker threads and 50 DB connections, full stop. So "the server can do N requests per second" is not a vibe; it's a number set by whichever of these resources runs out first. Rate limiting exists because once any one of those pools is exhausted, the server can't accept the 201st concurrent request — it queues, then times out, then falls over. Hold this picture: a request is a lease on scarce resources, not a free question.
0.2Why "just add more servers" doesn't remove the need for limiting#
From File 01 you know we scale horizontally by adding servers behind a load balancer. So why limit at all — why not add capacity? Three reasons. (1) Some resources don't scale out: if 100 app servers all share one database, adding app servers just lets them overwhelm the database faster — the bottleneck moved, it didn't vanish. (2) Scaling takes minutes; attacks and spikes take seconds: autoscaling a fleet is a 2–5 minute operation, but a scraper or a retry storm floods you in milliseconds — you need an instant defense, and shedding load is instant. (3) Cost: scaling to absorb a malicious 100× spike means paying for 100× capacity to serve traffic you don't even want. Limiting is the cheap, immediate complement to scaling.
0.3The client's half of the contract: retries and backoff#
A server rarely talks to a single well-behaved client. Real clients retry when they get an error — mobile apps, SDKs, microservices calling each other. If a client retries immediately and forever, then the moment your server is slightly overloaded and starts erroring, every client retries, doubling or tripling the load exactly when you least want it — a self-reinforcing death spiral called a retry storm. The healthy pattern (which you'll see referenced throughout) is exponential backoff with jitter: after a failure, wait 1s; if it fails again, wait 2s; then 4s, 8s… (exponential), each with a random ± spread added (jitter, so a thousand clients don't all retry at the same instant and re-synchronize the storm). Rate limiting (server side) and backoff (client side) are two halves of one stability contract — a limiter only works if clients eventually back off; otherwise you just reject the same flood repeatedly.
0.4Redis, and why distributed counting needs it (from File 02)#
Rate limiting means counting ("how many requests has this key made?"). On one server that's a local variable. But you have many servers (File 01), and the limit is usually global ("1000/min per user across the whole fleet"), so the count must be shared state all servers can read and update. From File 02 you know Redis — an in-memory (RAM-fast, ~0.1–0.5 ms), single-threaded key-value store. Two properties matter here: it's fast enough to touch on every request, and because it's single-threaded, operations like INCR (increment) and Lua scripts run atomically — no two servers can interleave and corrupt the count. That atomicity is the entire reason Redis is the default home for a distributed counter (Section 4). If "atomic," "single-threaded," or "INCR" are fuzzy, re-read File 02 §2.5 before Section 4.
0.5Queueing theory — the actual reason systems collapse instead of degrading#
This is the deepest "why" in the file, and almost nobody teaches it. The naive expectation is that a server at 100% utilization is simply "fully used" — efficient, even. The reality is that response time does not rise linearly with load; it rises hyperbolically and goes vertical near saturation. Rate limiting exists because of the shape of that curve.
The core result. For a simple queueing system where work arrives randomly, the average time a request spends waiting is approximately:
wait_time ≈ service_time × ( ρ / (1 − ρ) ) where ρ = utilization (0 to 1)Substitute real numbers, with a service time of 10 ms:
| Utilization ρ | ρ/(1−ρ) | Average wait | Total response time |
|---|---|---|---|
| 50% | 1.0 | 10 ms | 20 ms |
| 80% | 4.0 | 40 ms | 50 ms |
| 90% | 9.0 | 90 ms | 100 ms |
| 95% | 19.0 | 190 ms | 200 ms |
| 99% | 99.0 | 990 ms | 1,000 ms |
| 99.9% | 999.0 | 9,990 ms | 10,000 ms |
Read the last two rows. Going from 95% to 99% utilization — a mere 4 percentage points more traffic — makes your service five times slower. Going to 99.9% makes it fifty times slower. There is no "gracefully full" region. A system at 99% utilization is not "nearly optimal," it is milliseconds away from unbounded queueing, and the next small traffic increment does not cost you a small latency increment — it costs you the service.
Why the curve is shaped that way. Because arrivals are random, not evenly spaced. At 50% utilization the idle gaps are large enough to absorb the clumps. As utilization rises, the gaps shrink, so a clump of arrivals that would previously have been absorbed now has to wait behind the previous clump — and the queue from one clump has not drained before the next arrives. Near ρ = 1 the queue never fully drains, so waiting time grows without bound. This is not a property of bad software; it is arithmetic, and it applies to your thread pool, your database connection pool (§0.1), your disk, and the network card.
Little's Law, the other result you should be able to state: L = λ × W — the average number of requests in the system equals the arrival rate times the average time each spends there. It is unconditionally true for any stable system, which makes it a superb sanity check. If you receive 1,000 requests/sec and each takes 200 ms, you have 1000 × 0.2 = 200 requests in flight at all times — so you need at least 200 threads/connections, or you are already queueing. Run that multiplication before choosing a pool size; it converts a guess into arithmetic.
The two conclusions that justify this entire file:
- You must run with headroom. Target 60–70% utilization, not 95%, because the last 30% is your buffer against the hyperbolic region. Capacity you "waste" is capacity that keeps latency finite.
- When demand exceeds capacity, rejecting work is strictly better than queueing it. A rejected request costs you almost nothing and tells the client the truth immediately. A queued request occupies memory and a thread, times out anyway, gets retried (§0.3), and thereby increases arrival rate — pushing ρ further past 1. That is the mathematical shape of a death spiral, and shedding load is the only exit. When an interviewer asks "why not just queue everything," this is the answer.
0.6Time, clocks, and why windows are harder than they look#
Every algorithm in §3 is a statement about time, so you must know which clock is being read and how it lies.
Wall-clock time (System.currentTimeMillis(), time.time(), Redis's TIME) is "what time is it in the world." It is what you need for anything expressed in human terms ("resets at midnight"), and it is not monotonic: NTP adjustments, leap-second smearing, VM migrations, and manual changes can move it backwards. A rate limiter that computes elapsed = now − last_seen with a wall clock can get a negative elapsed time, which in a token-bucket refill computes a negative refill, or — worse, if the code clamps at zero — silently freezes the bucket.
Monotonic time (System.nanoTime(), time.monotonic(), CLOCK_MONOTONIC) only ever moves forward and measures elapsed duration, but it has no meaning across machines or across process restarts (it typically counts from boot). The rule: measure durations with a monotonic clock; express absolute deadlines with a wall clock; never mix them in one calculation.
Clock skew across machines is the distributed version of the problem. Two gateway nodes reading their own clocks will disagree by milliseconds normally, and by seconds when NTP is broken — which happens more often than you would like. In a sliding-window log (§3.2), node A writes a timestamp that node B considers to be in the future, and B's pruning logic either keeps entries it should drop or drops entries it should keep. Both directions are wrong, and the failure is silent and traffic-dependent — the worst debugging profile there is.
The mitigation is simple and you should state it by name: use one clock. Do the time arithmetic inside Redis — Redis's TIME command inside a Lua script gives every node the same reading, so skew between application nodes stops mattering entirely. (Note that Redis deliberately forbids calling TIME in older-style replicated scripts precisely because it makes them non-deterministic; modern Redis handles this with effect-based replication, and the practical answer is either to read TIME at the start of the script or to pass the server's time in as an argument.) Every serious distributed limiter does this, and knowing why — that the alternative is skew-dependent silent miscounting — is the interview-grade version of the answer.
Window alignment is the other time trap. If your fixed window is "each calendar minute," then every client in the world resets simultaneously, and you have manufactured a synchronized thundering herd at :00 of every minute — the same synchronization failure as cache avalanche (File 02 §6.2). Two fixes: derive the window start from a hash of the key so that different clients reset at different offsets, or use an algorithm with no shared boundary at all (token bucket, GCRA — §3.7), which is the better answer.
1Architectural Definition & "The Why"#
1.1The Plain Definition#
Rate limiting is the practice of controlling the rate at which clients (users, IPs, API keys, services) are allowed to consume a resource — typically "N requests per time window." When a client exceeds its allowance, further requests are rejected (usually with HTTP 429 Too Many Requests), delayed (throttled/queued), or degraded, rather than served.
Throttling is the closely-related term for slowing down rather than outright rejecting. Rate limiting is the enforcement of a quota; throttling is one way to enforce it (shape traffic to a ceiling).
1.2"The Why" — Protecting Finite Resources from Unbounded Demand#
Every backend has finite capacity — CPU, memory, DB connections, downstream API quotas. Demand, however, is unbounded: a buggy client in a retry loop, a viral spike, a scraper, a credential-stuffing attacker, or a DDoS can all send effectively infinite requests. Without a limiter, unbounded demand meets finite capacity and the result is collapse — the service falls over for everyone, including well-behaved users.
Rate limiting was invented/adopted to enforce fairness and stability:
- Prevent resource exhaustion & cascading failure. A limiter is a bulkhead — the term comes from shipbuilding: a bulkhead is a watertight wall dividing a hull into compartments, so a breach floods one compartment instead of sinking the whole ship. In systems, a bulkhead is any barrier that contains damage to a bounded slice of capacity. The limiter sheds excess load before it reaches and topples your backend, so overload drowns only the excess, not the service. Better to reject 5% of requests with a clean 429 than to let 100% of requests degrade into timeouts.
- Fairness / multi-tenancy. In a shared system, one greedy tenant must not starve others ("noisy neighbor"). Per-tenant limits guarantee everyone a fair slice.
- Cost control. If every API call costs you money (compute, third-party APIs, LLM tokens), unlimited usage = unlimited bill. Limits cap spend.
- Security / abuse prevention. Throttle brute-force logins (trying many passwords against one account), credential stuffing (the inverse: taking millions of username/password pairs leaked from other sites' breaches and replaying them against your login, betting on password reuse — it looks like millions of low-rate, plausible login attempts, which is exactly why per-IP limits alone miss it), scraping, and application-layer DoS.
- Monetization / tiering. "Free tier: 100 req/day; Pro: 10,000 req/day." Rate limits are the product boundary for API businesses.
Connecting to prior files: In File 02 we defended the database against avalanche/stampede miss-storms partly with rate limiting in front of the DB. Rate limiting is the generalized "protect the thing behind me from too much load" primitive, and it usually lives at the load balancer / API gateway (File 01).
2The Recursive Sub-Concept Tree#
2.1Sub-Concept: The Limiting Dimension (the "key")#
The core idea. A rate limit is always the answer to the question "how many requests has who made?" — and that word who is the thing you have to define precisely. You cannot limit "requests" as a global abstract blob, because you need separate running counters that reset independently: my 1000 requests must not use up your budget. So a limiter maintains one counter per identity, and the identity you count by is called the rate-limit key (it's literally the key in the Redis counter, e.g., ratelimit:user:42). Choosing what goes into that key is the single most important design decision in a limiter, because it decides who shares a budget with whom — and that determines fairness, resistance to abuse, and whether legitimate users get wrongly blocked. Let's walk through each option and, critically, why each has the trade-off it does.
Per IP address — key = the client's source IP. This is the easiest to implement because every request already carries a source IP; you need no login, no account, nothing. That's why it's the go-to for blunt, pre-authentication defense (a scraper hitting your public homepage, a volumetric DDoS). But it has two serious weaknesses rooted in how the internet actually works. First, NAT (Network Address Translation) and corporate/mobile proxies make many humans share one IP: an entire office building, a university, or a mobile carrier's users can all appear to your server as a single IP address. So an IP-based limit of "100/min" might be starving 5,000 real people who happen to sit behind one corporate gateway — you block innocents. Second, it's trivially evadable by a motivated attacker, because IP addresses are cheap and rotatable: cloud IPs, botnets, and residential-proxy services let an attacker spread their flood across thousands of IPs, so each individual IP stays under your limit while the aggregate still crushes you. Net: IP keying is a cheap first layer, never a complete answer.
Per user ID / account — key = the authenticated user's ID. This is fair in the way IP is not: the limit follows the human/account regardless of what network they're on, so the office building's 5,000 users each get their own budget, and an attacker can't dodge it by rotating IPs (they'd have to create thousands of accounts, which is a higher bar you can defend separately). The cost is that it requires authentication — you only know the user ID after you've verified their login/token, which means (a) it's useless for limiting unauthenticated endpoints like the login page itself (chicken-and-egg: you're trying to throttle login attempts but don't have a user until login succeeds), and (b) the auth step itself costs resources, so a flood of unauthenticated junk can still hurt you before user-keying kicks in. In practice you layer IP-keying on public/auth endpoints and user-keying on everything behind login.
Per API key / client ID — key = the API token identifying a calling application (not a human). This is the standard for API products (Stripe, Twilio, GitHub API), because your customer is a program, and the API key both authenticates it and identifies which paying account it belongs to. It's the cleanest of all: strong identity (the key is a secret credential, hard to forge), directly tied to a billing tier (so you can enforce "free tier = 100/day, enterprise = 1M/day" by looking up the key's plan), and not subject to the NAT problem (each integration has its own key). This is why it's the backbone of monetized APIs.
Per endpoint — key includes which route is being called, so different endpoints get different limits. The reason you need this: not all requests cost the same (recall §0.1 — a request is a lease on resources, and some leases are far heavier). A GET /health that returns a static "OK" costs almost nothing; a POST /search that scans a database or a GET /export that generates a 50MB report costs thousands of times more CPU, memory, and DB time. A single blanket "1000 requests/min" limit is wrong for both — it's absurdly loose for /export (100 concurrent exports would melt you) and needlessly tight for /health. So you tighten the expensive endpoints (/export: 5/min) and loosen the cheap ones. You rarely key only by endpoint; you combine it with an identity (next item).
Global — key = a single fixed constant, so all traffic shares one counter. Use this to protect a shared downstream dependency with a hard ceiling, regardless of who's calling. Example: your service calls a third-party payment API that permits only 500 requests/sec total across your whole company — exceed it and they start rejecting you. A global limiter caps your aggregate outbound rate to 500/s so you never trip their limit and get your account throttled. It's about protecting a resource that has an absolute limit, not about fairness between callers.
Composite — key = a combination of the above, e.g., (api_key, endpoint). This is what real systems almost always use, because you usually want several of the properties at once. (api_key + endpoint) means each customer gets a separate budget per endpoint: customer A hammering /search uses up only their /search budget, not their /export budget and not customer B's /search budget. You can go further — (user_id, endpoint, method) — to slice as finely as the abuse patterns require. The trade-off is counter explosion: every distinct key is a distinct counter in Redis, so (user × endpoint) for 10M users × 50 endpoints = up to 500M possible counters (mitigated by short TTLs so idle counters expire, but it's a real memory-and-cardinality consideration).
The decision, summarized. You're trading ease of implementation (IP needs nothing; user/API-key needs auth) against fairness (does one entity's abuse hurt innocents who share its key?) against abuse-resistance (how hard is the key to rotate/forge?). There is no single right key — production systems stack them: a coarse per-IP limit at the edge to shed volumetric junk cheaply, then a fair per-user/per-API-key limit behind auth, often made composite with the endpoint. Interviewers probe this precisely because naming "per user" without explaining why not just IP (the NAT and rotation problems) is the tell of someone who memorized a list versus understood the trade-off.
2.2Sub-Concept: Hard vs. Soft Limits; Rejection vs. Throttling vs. Shaping#
When a client crosses its limit, you have a choice about what "enforcement" means — and it's genuinely a choice, with different UX and safety implications, not a single fixed behavior.
Hard limit → reject. The strict version: once the counter says "over," the request is refused outright with a 429 Too Many Requests (§2.4). Nothing is processed; the client must wait and retry. This is the safest for you (zero extra work done for over-limit requests — you protect the backend maximally) and the correct default for abuse defense and hard capacity ceilings. Its downside is bluntness: a legitimate user who briefly spikes gets a hard error.
Soft limit → allow but flag. A gentler version: when the client crosses the threshold, you still serve the request, but you record the breach and act on it out-of-band — log a warning, emit a metric, bill overage charges, drop the request's priority, or send the customer a "you're approaching your limit" email. This is common in monetization ("we won't cut you off mid-launch, but you'll be invoiced for the overage") and in gradual rollouts of a new limit (observe who would be blocked before you actually block them). The trade-off is obvious: you're accepting the extra load and cost in exchange for a friendlier experience and better signal.
Throttling / shaping → delay, don't drop. Instead of rejecting the over-limit request, you hold it in a queue and release it later, at the permitted rate. The client's request eventually succeeds — it just takes longer. This smooths a bursty client into a steady stream rather than dropping their excess. It's ideal when the requests genuinely need to complete (you don't want to lose them) and a bit of added latency is acceptable — e.g., a batch job, or protecting a fragile downstream that needs a constant feed. The cost is that queuing adds latency and consumes memory (the queued requests sit somewhere), and under sustained overload the queue eventually fills and you're back to dropping. This delay-based enforcement is exactly what the leaky bucket algorithm (§3.5) implements.
How to choose: reject (hard) for abuse and hard ceilings where doing no work for bad traffic is the point; soft for monetization and safe rollouts; throttle/shape when the requests are legitimate and worth delaying rather than losing.
2.3Sub-Concept: Burst vs. Sustained Rate#
This distinction is why simplistic limiters feel wrong in practice, so it's worth understanding deeply. Real traffic is not smooth — it is bursty. A user opening your dashboard fires 20 API calls in the first 200 milliseconds (loading widgets, avatars, notifications) and then nothing for 30 seconds while they read. A mobile app coming back from background syncs a batch at once. This is normal, legitimate behavior. So a limit has to describe two different things:
Sustained (average) rate — the rate you can keep up forever, the long-run ceiling. "100 requests per second, on average, indefinitely." This is set by your steady-state capacity (§0.1): the rate at which your threads/DB/CPU can churn through work without a growing backlog.
Burst capacity — how big a momentary spike above the average you'll tolerate before clamping down. "You may fire up to 500 requests in a quick burst, then you're held to the 100/s sustained rate." This exists because short spikes are harmless: your server has buffers and brief idle capacity, so absorbing a 500-request burst that then goes quiet is fine — the average over any real window is still fine.
Why you need both, concretely. Imagine you only enforced the sustained rate as a rigid "no more than 100 in any given second." The dashboard user's legitimate 20-calls-in-200ms opening would be fine (20 < 100), but a slightly heavier page firing 150 calls on load would be wrongly blocked even though the user then sits idle for a minute — their true average is trivial. Conversely, if you only allowed bursts with no sustained cap, a client could sustain the burst rate forever and overwhelm you. So the right limit is "average X with bursts up to Y" — and the beautiful thing is that the token bucket algorithm (§3.4) models exactly this pairing natively: the bucket size is the burst capacity Y, and the refill rate is the sustained rate X. When you see token bucket described as "the most popular algorithm," this is why — it's the one that matches how real traffic actually behaves.
2.4Sub-Concept: The 429 Response & Retry-After#
Retry-After converts a refusal into a scheduling instruction, and the
X-RateLimit-* trio lets a well-behaved client avoid hitting the wall at all. Pair them with
exponential backoff plus jitter on the client — without jitter you have simply agreed on a time for
everyone to attack you again.The standard rejection is HTTP **429 Too Many Requests**. A well-behaved API includes headers so clients can adapt:
Retry-After: 30— wait 30 seconds before retrying.X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset— how many you get, how many are left, when the window resets.
Well-designed limits are communicative — they tell clients how to behave, which reduces retry storms.
2.5Sub-Concept: Idempotency & Retries (why limiting and retrying interact)#
Clients retry failed/429'd requests. If they retry aggressively without backoff, they amplify the overload the limiter is trying to prevent (a retry storm). Correct client behavior is exponential backoff with jitter (wait 1s, 2s, 4s… plus randomness). Rate limiting on the server and backoff on the client are two halves of the same stability contract.
The retry-amplification arithmetic, because the number is worse than intuition suggests. Suppose a client retries up to 3 times. In healthy operation, load is 1×. The moment your service starts failing, every failed request becomes up to 4 requests — your load quadruples exactly when you have the least capacity to serve it. Now add a second tier: if service A calls service B, and both retry 3×, a single user request can become 16 requests at the bottom of the stack. Three tiers: 64. This is retry amplification, and it is how a brief blip becomes a total outage.
Three mitigations you should be able to name:
- Exponential backoff with jitter — and specifically full jitter:
sleep = random(0, min(cap, base × 2^attempt)), notbase × 2^attempt ± small_noise. AWS's published analysis showed that full jitter dramatically outperforms partial jitter at spreading a synchronized herd, because partial jitter still leaves clients clustered around the same target time. - Retry budgets — a client may spend retries only up to a fraction (commonly 10%) of its successful request volume. Once the budget is exhausted, failures propagate immediately instead of being retried. This caps amplification at 1.1× rather than 4×, and it is the mechanism Envoy and gRPC use. It is strictly better than "max 3 retries" because it is a fleet-level bound, not a per-request one.
- Circuit breakers — after a threshold of failures, stop calling the downstream entirely for a cooling-off period, then let a single probe through to test recovery. This converts a flood of doomed retries into zero traffic, giving the downstream room to recover. Fully covered in File 10.
The interview-grade point: a rate limiter that returns 429 to clients that immediately retry has not shed load — it has converted expensive work into cheap work, which helps, but the flood continues. Load shedding only truly works when the client cooperates, which is why Retry-After (§2.4) and client-side backoff are part of the limiter's design, not separate concerns.
2.6Sub-Concept: Rate vs. Quota vs. Concurrency — three different limits people conflate#
These three answer different questions, protect against different failures, and are frequently confused in interviews. Get them straight.
A rate limit bounds requests per unit of time: "100 requests per second." It protects against throughput exhaustion — too much work arriving per second for your CPU, database, or downstream to keep up. Every algorithm in §3 implements this.
A quota bounds total consumption per long period, usually for billing rather than for stability: "1,000,000 requests per month," "50 GB of egress per day." The distinguishing property is that a quota has no burst behaviour and no smoothing intent — it is an accounting total, reset on a calendar boundary, and its enforcement is typically "you are cut off" or "you are billed for overage" (§2.2's soft limit). Implementation differs too: a quota counter must be durable (losing the month's count to a Redis restart is a billing incident), whereas a rate-limit counter is disposable by design. Do not store quotas in a cache with an eviction policy — that is File 02 §10.7's anti-recommendation appearing here in a new costume.
A concurrency limit bounds requests in flight simultaneously: "at most 50 concurrent requests per tenant." This is the one people forget, and it protects against a failure that rate limiting cannot touch. Consider: a tenant sends 10 requests per second, comfortably under a 100/s rate limit — but each request is a report that takes 60 seconds. By Little's Law (§0.5), they now hold 10 × 60 = 600 requests in flight, occupying 600 threads and 600 database connections. Your rate limiter is perfectly satisfied while your server has no threads left. Rate limits bound arrival; only concurrency limits bound occupancy — and occupancy is what actually exhausts pools. This is exactly why Stripe runs a concurrency limiter alongside its rate limiter (§9.1), and saying so is a strong senior signal.
The implementation is a semaphore, not a counter: acquire a permit on entry, release it on exit (in a finally block, always — a leaked permit is a permanent capacity loss that looks like a slow memory leak in your throughput). Distributed version: a Redis set or counter that is incremented on start and decremented on completion, with a TTL on each entry so that a client that crashes mid-request does not hold its permit forever. That TTL is mandatory: without it, one crash per hour silently shrinks your concurrency limit to zero over a day.
Use all three together. Rate limit for throughput, concurrency limit for occupancy, quota for billing. They are complements, not alternatives.
2.7Sub-Concept: Cost-Based (Weighted) Limiting#
The assumption baked into "100 requests per minute" is that all requests cost the same. §2.1 already noted they do not; cost-based limiting is the fix that takes that seriously.
The mechanism: instead of decrementing the counter by 1 per request, decrement it by that request's cost, computed from what the request actually consumes. A token bucket (§3.4) handles this natively — spend n tokens instead of 1 — which is one more reason it is the dominant algorithm.
Where cost is known in advance: a static weight per endpoint. GET /health costs 1, GET /users/:id costs 1, POST /search costs 10, GET /export costs 500. The client's budget is then denominated in "cost units per minute," and one export consumes what 500 health checks would. GitHub's GraphQL API does exactly this — it computes a query's point cost from the query itself (how many nodes it will traverse) before executing it, which is the only sane way to limit an API where a single request can ask for arbitrarily much.
Where cost is only known afterwards — and this is the modern case that matters — you must limit on consumption, not requests. LLM APIs are the canonical example: Anthropic's and OpenAI's APIs limit on tokens per minute alongside requests per minute, because one request can consume a hundred thousand times more compute than another. The pattern is: reserve an estimate up front, reconcile the actual afterwards. Deduct the estimated token count when the request starts (so concurrent requests cannot all sail through on a stale balance), then refund or additionally charge the difference when it completes. Databases do the analogous thing with I/O credits; AWS does it with burst-balance mechanics on EBS and older EC2 instance classes.
The trade-off: cost-based limiting is fair in the way request-counting is not — it prices the actual resource — but it requires you to know the cost, either by static assignment (which drifts as your code changes and someone forgets to update the weight) or by estimation (which is wrong, sometimes badly). The mitigation: measure real per-endpoint cost from production telemetry and revisit the weights, rather than assigning them once from intuition.
2.8Sub-Concept: Determining the Client's Identity Safely (the X-Forwarded-For trap)#
§2.1 said "key by IP" as though the IP were simply available. Behind a proxy it is not, and getting this wrong produces a limiter that is trivially bypassed — a security hole, not a bug.
Recall File 01: a full-proxy load balancer terminates the client's connection, so your application's socket shows the proxy's IP, not the client's. The real address is passed in a header — X-Forwarded-For (a comma-separated chain, appended to by each proxy), or Forwarded (the standardized version, RFC 7239), or the Proxy Protocol at L4. So the application reads the header.
And now the vulnerability: headers are client-supplied data. An attacker simply sends X-Forwarded-For: 1.2.3.4 with a random value on every request, and if your limiter trusts it, every request appears to come from a different client and your rate limiter does nothing at all while reporting healthy metrics. Worse, an attacker can forge the IP of a victim and get them blocked — turning your limiter into a denial-of-service tool aimed at your own users.
The correct rule: trust exactly as many entries as you have proxies you control, counting from the right. If you run one load balancer, the client IP is the last entry in the chain (the one your LB appended); everything to the left was supplied by the client and is untrustworthy. Frameworks express this as a "trusted proxy count" or a list of trusted proxy CIDRs — configure it explicitly. Cloud providers give you an unforgeable equivalent (CloudFront's CloudFront-Viewer-Address, Cloudflare's CF-Connecting-IP) precisely because these are set by their edge and stripped from client input.
Beyond IP: for abuse resistance you often need identity that survives IP rotation — a device fingerprint, a TLS fingerprint (JA3/JA4, which hashes the client's TLS handshake parameters and is far harder to vary than an IP), an account, or a payment instrument. Each step up that ladder is harder to forge and harder to obtain, and the honest interview answer is that you layer them rather than picking one.
One more subtlety worth naming: IPv6. Limiting a single IPv6 address is nearly meaningless, because a residential customer is typically handed a /64 — eighteen quintillion addresses — and can rotate freely inside it at zero cost. Limit IPv6 clients by prefix (/64 for a host, /56 or /48 for a site), not by address. Getting this wrong means your IPv6 limiter is decorative.
2.9Sub-Concept: Load Shedding and Priority#
Rate limiting asks "has this client had too much?" Load shedding asks a different question: "is the system in trouble right now, and if so, what should I drop?" The distinction matters because a system can be overloaded while every individual client is within its limit — a flash sale, a viral moment, or a partial capacity loss all produce this.
The mechanism: monitor a real saturation signal and, when it degrades, start rejecting a fraction of traffic. The signal choice is the whole design:
- CPU utilization — easy, but a lagging indicator, and misleading for I/O-bound services (which can be fully saturated at 20% CPU).
- Queue depth / thread-pool saturation — better, because it directly measures §0.5's queue.
- Queueing delay (how long a request waited before being picked up) — the best signal, because it is exactly the quantity that goes hyperbolic near saturation, and it is measured in the units your users feel. This is the insight behind CoDel (Controlled Delay), originally an anti-bufferbloat network algorithm, now used for application load shedding (Facebook adopted it for this): if the minimum queueing delay over a window exceeds a target, start dropping — using the minimum rather than the average makes it robust against brief clumps.
Priority is what makes shedding acceptable. Dropping traffic at random damages everything equally. Instead, classify traffic by criticality and shed from the bottom: prefetch and analytics beacons first, then background sync, then browsing, and never checkout, payment, or auth. Google's published approach uses a small number of criticality levels propagated through the whole call chain, so a downstream service knows whether the request it is serving ultimately belongs to a checkout or to a dashboard refresh. This propagation is the hard part — criticality must ride in the request context across every hop or the deepest service, the one that most needs to shed correctly, is flying blind.
The trade-off: shedding keeps the service alive for the traffic that matters, at the cost of visibly failing some requests and of the machinery to classify them. The failure mode to avoid: shedding based on a signal that the shedding itself improves, with no damping — you drop traffic, the signal recovers, you stop dropping, the flood returns, you drop again. That oscillation is worse than steady partial shedding; damp it with hysteresis (different thresholds for entering and leaving the shedding state).
2.10Sub-Concept: Backpressure#
Backpressure is the generalization of everything above: when a consumer cannot keep up, it propagates that fact upstream so producers slow down, rather than silently accumulating an unbounded queue.
You have already met it without the name. TCP has it built in: the receive window in every TCP header (File 01 §0.5) tells the sender exactly how much more the receiver is willing to accept — when the receiver's buffer fills, it advertises a zero window and the sender stops transmitting. That is backpressure implemented in the transport, and it is why TCP does not melt down when a fast server talks to a slow client.
The application-level equivalents: bounded queues that block or reject on put (never unbounded — an unbounded queue is a memory leak with extra steps and converts a fast failure into a slow, total one), reactive-streams demand signalling (request(n): the consumer states how many items it can take), gRPC and HTTP/2 flow control per stream, and consumer-driven pull in message systems (Kafka consumers pull at their own pace, which is exactly why Kafka absorbs bursts so well — File 07).
Why it beats rate limiting where it applies: a rate limit is a guess at the right rate, configured in advance and stale the moment your hardware or code changes. Backpressure is measured — the consumer's actual capacity, right now, communicated directly. Why you still need rate limiting: backpressure requires a cooperative producer inside your trust boundary. An anonymous attacker on the internet will not honour your demand signal, and a third-party client will not either. The rule: backpressure inside your system, rate limiting at its edges.
2.11Sub-Concept: Adaptive / Dynamic Limits#
Every limit discussed so far is a static number a human chose — and that number is wrong the moment you deploy new hardware, ship a performance regression, lose an availability zone, or add a caching layer. Adaptive limiting derives the limit from live measurement instead.
The mechanism, borrowed directly from TCP congestion control: AIMD — additive increase, multiplicative decrease. While the system is healthy (latency stable, errors low), raise the limit by a small increment. The moment a congestion signal appears (latency rising, errors, queueing delay past target), cut the limit by a multiplicative factor — typically halve it. The asymmetry is deliberate and is the reason TCP works: you probe for more capacity slowly and retreat from overload fast, because the cost of overshooting is far higher than the cost of under-using.
The concrete implementation most worth naming is **Netflix's concurrency-limits library, which applies Little's Law (§0.5) in reverse: it continuously measures the minimum observed latency (an estimate of the service time with no queueing) and the current latency, infers how much queueing exists, and adjusts the concurrency limit to keep queueing near zero. The elegance is that it needs no configuration at all** — no one has to guess "50 concurrent requests"; the system discovers its own capacity and re-discovers it after every deploy.
- Pros: self-tuning; correct after hardware and code changes; degrades gracefully during partial failures (if a dependency slows, the limit tightens automatically).
- Cons: harder to reason about ("why was I limited?" has no fixed answer), can oscillate without damping, and it is unsuitable for limits that are contractual — a customer paying for 10,000 requests/minute must get exactly that, not whatever your adaptive controller decided this afternoon.
- When to use which: adaptive limits for protecting yourself (internal service-to-service, overload protection), static limits for promising something to someone else (published API tiers, billing quotas). That sentence is the decision rule.
3The Algorithms — Exhaustive Breakdown#
This is the core of the file. There are five canonical algorithms. Know each one's mechanics, memory cost, burst behavior, and boundary accuracy.
3.1Fixed Window Counter#
Mechanism: Divide time into fixed windows (e.g., each calendar minute). Keep a counter per key per window. Increment on each request; if the counter exceeds the limit within the current window, reject. At the window boundary, reset the counter to 0.
limit = 100 / minute
key "user:42" -> counter for window [12:00:00 - 12:00:59]
request -> counter++ ; if counter > 100 -> 429
at 12:01:00 -> counter resets to 0- Pros: Trivially simple, O(1) memory per key (just a counter + window id), fast. Easy to implement in Redis with
INCR+EXPIRE. - Cons — the boundary burst problem: a client can send 100 requests at
12:00:59and another 100 at12:01:00— 200 requests in 2 seconds, double the intended rate, because the two bursts straddle the window reset. This is the classic flaw. - Use when: approximate limiting is fine and simplicity matters.
3.2Sliding Window Log#
Mechanism: Store a timestamp for every request (a log) per key, in a sorted structure. On each new request, drop timestamps older than now - window, then count what remains; if count < limit, allow and append the new timestamp; else reject.
On request at time T:
remove all timestamps < (T - window)
if len(log) < limit: append T; ALLOW
else: REJECT- Pros: Perfectly accurate — enforces exactly "no more than N in any rolling window." No boundary burst.
- Cons: Memory-expensive — stores every request timestamp (a client doing 10,000 req/window stores 10,000 timestamps). Higher compute to prune. Doesn't scale to high volume.
- Use when: accuracy is critical and volume is modest. Implemented in Redis with a sorted set (
ZADDtimestamp,ZREMRANGEBYSCOREto prune,ZCARDto count).
3.3Sliding Window Counter (the pragmatic favorite)#
Mechanism: A hybrid that approximates the sliding log with fixed-window memory. Keep counters for the current and previous fixed windows, and compute a weighted estimate based on how far into the current window we are.
limit = 100/min. Previous minute count = 80, current minute count = 30.
We are 25% into the current minute (15s of 60s elapsed).
estimate = current + previous * (1 - elapsed_fraction)
= 30 + 80 * (1 - 0.25) = 30 + 60 = 90 -> under 100, ALLOW- Pros: O(1) memory (two counters), smooths the fixed-window boundary burst, good enough accuracy for almost everyone. The industry default (this is essentially what Cloudflare uses at scale).
- Cons: Approximate (assumes uniform distribution within the previous window); can be slightly off, but errors are small and bounded.
- Use when: you want fixed-window cheapness with near-sliding-log accuracy. Best general-purpose choice.
3.4Token Bucket (the burst-friendly workhorse)#
burst=) because it encodes the two
numbers a product actually has: a sustained rate and a tolerated burst. The lazy refill
trick is the implementation detail worth stating out loud — you never run timers, you just multiply elapsed time by the
rate when the key is next touched.Mechanism: Imagine a bucket that holds up to B tokens (the burst capacity). Tokens are **refilled at a steady rate r tokens/sec** (the sustained rate), up to the cap B. Each request must remove one token to proceed. If the bucket has a token, allow and decrement; if empty, reject (or wait).
bucket capacity B = 500 (burst), refill r = 100 tokens/sec (sustained)
- Idle client accumulates up to 500 tokens.
- A burst of 500 requests drains the bucket instantly -> all allowed.
- After that, requests succeed only as fast as tokens refill (100/s).- Pros: Naturally models "average rate + burst" — exactly the real-world requirement (2.3). Smooth, memory-cheap (store token count + last-refill timestamp per key — refill is computed lazily). The most widely used algorithm (AWS API Gateway, Stripe, many gateways).
- Cons: Allows bursts (which is usually desired, but sometimes you specifically want to forbid bursts → use leaky bucket). Two parameters to tune (
Bandr). - Lazy refill trick: you don't run a background refiller; on each request you compute
tokens = min(B, tokens + (now - last_refill) * r)then try to spend one. O(1), no timers.
3.5Leaky Bucket (the smoother / traffic shaper)#
Mechanism: Requests enter a queue (the bucket). The queue is drained (processed) at a fixed, constant rate — like water leaking from a hole at the bottom. If the queue is full, new requests overflow and are dropped.
Requests arrive bursty -> enter FIFO queue (size Q)
Processor pulls from queue at a CONSTANT rate r (e.g., 100/s)
Queue full -> drop new arrivals- Pros: Output is perfectly smooth/constant regardless of bursty input — ideal when the downstream needs a steady, predictable rate (e.g., protecting a fragile legacy system or a metered third-party API). Traffic shaping.
- Cons: No bursting — even if the client was idle, it can't burst; requests wait in the queue, adding latency. Requires a queue (memory). Under sustained overload the queue fills and requests are dropped or delayed.
- Token bucket vs leaky bucket (classic question): Token bucket allows bursts up to
Bthen enforces averager(bursty-friendly). Leaky bucket enforces a constant output rate with no bursts (smooth). Token bucket = "spend saved-up allowance in a burst"; leaky bucket = "everything flows out at one steady speed."
The distinction that trips people up: leaky bucket has two forms. What §3.5 describes is the leaky bucket as a queue (a shaper) — requests are held and released at a constant rate, so nothing is dropped until the queue overflows, and latency is added deliberately. There is a second form, the leaky bucket as a meter (a policer), in which nothing is queued at all: you merely track a virtual water level that rises by one on each request and drains at rate r, and you reject any request that would overflow the bucket. The meter form is mathematically equivalent to the token bucket — one counts water rising toward a ceiling, the other counts tokens falling toward zero, and they admit exactly the same request sequences. Knowing that these are the same algorithm viewed from two ends, and that only the queue form actually shapes traffic, is a genuine senior distinction, and it is the bridge to GCRA below.
3.6GCRA — The Generic Cell Rate Algorithm (the elegant one nobody teaches)#
What it is. GCRA comes from ATM telecom networking, where it was the standard traffic policer, and it is a token bucket that stores a single timestamp instead of a counter. It is the algorithm behind Redis's redis-cell module and several production limiters, and it is worth knowing precisely because it eliminates the two operational annoyances of token bucket: storing two mutable fields, and having to reason about refill.
The insight. Rather than track "how many tokens do you have," track "what is the earliest time at which your next request would be conforming?" — a single value called the TAT (Theoretical Arrival Time). Everything else falls out of arithmetic.
The mechanism. Two parameters: the emission interval T = 1 / rate (the minimum spacing between requests at the sustained rate) and the burst tolerance τ = (burst − 1) × T (how far ahead of schedule a client is allowed to run). On a request at time t:
if TAT is unset: TAT = t
if t < TAT − τ: REJECT (too far ahead of schedule)
else: ALLOW, and TAT = max(TAT, t) + TWorked example. Rate = 1 request/second (T = 1 s), burst = 3 (τ = 2 s). Client is idle, then fires four requests at t = 10.0:
t=10.0 TAT unset -> TAT=10.0. 10.0 < 10.0−2 ? no -> ALLOW, TAT = 11.0
t=10.0 10.0 < 11.0−2 = 9.0 ? no -> ALLOW, TAT = 12.0
t=10.0 10.0 < 12.0−2 = 10.0 ? no -> ALLOW, TAT = 13.0
t=10.0 10.0 < 13.0−2 = 11.0 ? YES -> REJECTExactly three allowed in the burst, then rejection — matching the intended burst of 3 — and the client may proceed again at t = 11.0, one per second thereafter. Notice what you get for free: **TAT − τ − t is precisely how long the client must wait**, so Retry-After (§2.4) comes out of the algorithm exactly rather than as an estimate. That is a real advantage in practice; most limiters can only approximate that header.
- Pros: one value stored per key (a single timestamp, so it fits in one Redis
SETand there is no read-modify-write of two fields); exactRetry-Afterfor free; no window boundaries at all, so no boundary burst and no synchronized reset (§0.6); constant time and constant memory. - Cons: the arithmetic is unintuitive on first reading, which is the entire reason it is under-used; it is a policer, so like token bucket it does not smooth output; and expressing "burst" via
τconfuses people who expect a bucket size. - Complexity: O(1) time, O(1) memory — one timestamp, one round trip.
- When to use: whenever you would reach for a token bucket but want minimal state and a correct
Retry-After, and especially in Redis where storing one value beats storing a hash of two. If you name GCRA in an interview and can explain the TAT, you have demonstrated you have read past the standard blog-post list of four algorithms.
3.7Concurrency Limiting (the semaphore) — the algorithm that is not about time#
Included here because §2.6 established that rate limits cannot bound occupancy, and occupancy is what exhausts pools. This is a limiting algorithm even though it has no time window at all.
Mechanism. Maintain a count of in-flight requests per key. On arrival, if in_flight < limit, increment and proceed; otherwise reject (or queue). On completion — success, error, timeout, or panic — decrement.
acquire: if in_flight >= limit -> REJECT
in_flight += 1
...serve the request...
release: in_flight -= 1 <-- MUST be unconditional (finally / defer)Distributed version. A Redis sorted set per key, where each in-flight request adds a member (a unique request ID) scored by its start time. On arrival: remove all members older than a timeout (that is your crash-recovery mechanism), count what remains with ZCARD, and add the new member if under the limit — all in one Lua script for atomicity (§4.2). On completion, ZREM the member.
- Pros: directly bounds the resource that actually runs out (threads, connections, memory); self-adjusting to request cost with no configuration — if requests slow down, fewer fit under the limit automatically, which is exactly the behaviour you want during a downstream slowdown; by Little's Law it implicitly bounds latency.
- Cons: a leaked permit permanently reduces capacity, so the timeout-based cleanup is mandatory, not optional; it says nothing about arrival rate, so a client sending millions of instantly-failing requests is unaffected; picking the limit requires knowing your pool sizes.
- Complexity: O(1) local; O(log n) per operation for the Redis sorted-set version, with memory proportional to in-flight count (small by definition).
- When to use: always, in addition to a rate limit, wherever requests have highly variable duration — search, exports, report generation, LLM calls, anything calling a slow third party. This pairing is what Stripe does (§9.1).
3.8Adaptive Limiting (AIMD) — the algorithm with no configured limit#
The mechanised form of §2.11, included because "the limit itself is a variable" is a genuinely different algorithm, not a tuning detail.
Mechanism. Maintain a current limit L. Continuously sample a health signal — queueing delay, error rate, or latency versus the minimum observed latency. Then:
every measurement window:
if healthy: L = L + 1 (additive increase — probe carefully)
if congested: L = L × 0.5 (multiplicative decrease — retreat fast)
clamp L to [L_min, L_max]Why the asymmetry is correct, and why it is not arbitrary. Overshooting the limit costs you a queue that goes hyperbolic (§0.5); undershooting costs you only some unused capacity. The costs are wildly asymmetric, so the response must be too: creep upward, collapse downward. This is exactly TCP's congestion window, and it is one of the most-validated control laws in computing — it is what keeps the internet from congestion collapse.
The Netflix refinement (gradient-based). Rather than a binary healthy/congested signal, compute gradient = latency_minimum / latency_current. A gradient near 1 means no queueing (current latency equals the no-load latency), so grow; a gradient well below 1 means requests are queueing, so shrink proportionally to how bad it is. This converges faster and oscillates less than binary AIMD.
- Pros: zero configuration; automatically correct after deploys, hardware changes, and dependency slowdowns; protects against the failures you did not predict.
- Cons: oscillation without damping; unexplainable rejections ("why was I limited?" has no static answer); unsuitable for contractual limits (§2.11); needs a good signal — driven by CPU it will misbehave on I/O-bound services.
- Complexity: O(1) per request plus a periodic control loop; the real cost is the latency histogram you must maintain to compute percentiles cheaply.
- When to use: internal overload protection, service-to-service. Never as the enforcement mechanism for a published quota.
3.9Algorithm Comparison Table#
| Algorithm | Memory/key | Bursts? | Accuracy | Smoothing | Notes |
|---|---|---|---|---|---|
| Fixed Window | O(1) | Yes (2× at boundary) | Low (boundary bug) | No | Simplest; approximate |
| Sliding Log | O(N requests) | No | Perfect | No | Accurate but memory-heavy |
| Sliding Window Counter | O(1) | Minimal | High (approx) | Partial | Best general default |
| Token Bucket | O(1) | Yes (up to B) | High | No (allows burst) | Most popular; models avg+burst |
| Leaky Bucket | O(queue) | No | High | Yes (constant out) | Traffic shaping; adds latency |
| GCRA | O(1) — one timestamp | Yes (up to τ) | High | No | Exact Retry-After; no window boundary |
| Concurrency (semaphore) | O(1) local / O(in-flight) distributed | n/a | Exact | n/a | Bounds occupancy, not arrival rate |
| Adaptive (AIMD) | O(1) + histogram | Varies | Self-tuning | No | No configured limit; internal use only |
Fuller comparison across the dimensions that decide real choices:
| Algorithm | Redis primitives | Round trips | Boundary bug? | Exact Retry-After? | Shapes output? | Config knobs |
|---|---|---|---|---|---|---|
| Fixed window | INCR + EXPIRE | 1 (Lua) | Yes — 2× | Approximate | No | limit, window |
| Sliding log | ZADD/ZREMRANGEBYSCORE/ZCARD | 1 (Lua) | No | Exact | No | limit, window |
| Sliding counter | 2 × counter | 1 (Lua) | Nearly eliminated | Approximate | No | limit, window |
| Token bucket | hash (tokens + timestamp) | 1 (Lua) | No | Computable | No | rate, burst |
| Leaky bucket (queue) | list + worker | 1 + drain | No | Exact (queue position) | Yes | rate, queue size |
| GCRA | single string (TAT) | 1 (Lua) | No | Exact, free | No | rate, burst |
| Concurrency | sorted set | 1 (Lua) ×2 | n/a | n/a | Indirectly | max in-flight, timeout |
| Adaptive | local + telemetry | 0 | n/a | n/a | No | none |
3.10The Decision Rule#
Answer in this order; each question eliminates alternatives:
- Are you bounding arrival rate, or occupancy? If requests vary wildly in duration, you need a concurrency limit (§3.7) — possibly instead of a rate limit, certainly in addition. This is the question most candidates never ask, and it is the one that catches the real production failure.
- Must the downstream receive a perfectly smooth stream? If yes — a fragile legacy system, a metered third-party API, a device that cannot buffer — you need the leaky bucket queue (§3.5), and you accept added latency and memory. Nothing else shapes output.
- Do you need "average rate plus burst"? That is nearly always the honest requirement (§2.3), and it means token bucket (§3.4) or GCRA (§3.6). Pick GCRA when you want minimal state and an exact
Retry-After; pick token bucket when you want the mental model everyone on your team already has, or when you need cost-based spending of multiple tokens per request (§2.7). - Do you just need a simple cap and can tolerate ~2× at boundaries? Fixed window (§3.1) — and be honest that you are choosing simplicity over accuracy. Add hashed window offsets (§0.6) so all clients do not reset together.
- Do you need better accuracy at O(1) memory? Sliding window counter (§3.3) — the best accuracy-per-byte in the table, and the correct default for a general-purpose API limiter.
- Is the limit a security or billing boundary where "approximately" is unacceptable? Sliding window log (§3.2) — pay the memory for exactness. Use it for "5 password resets per day," never for "10,000 API calls per minute."
- Are you protecting yourself internally rather than promising something to a customer? Adaptive/AIMD (§3.8), because a static number is a guess that expires with your next deploy.
In one line: token bucket or GCRA for public APIs, sliding-window counter when you want cheap accuracy, leaky-bucket-queue when the downstream demands smoothness, sliding-window log when exactness is contractual, and a concurrency limit alongside all of them because none of them bound occupancy.
4Distributed Rate Limiting#
The single-server case is easy (a local counter). The hard, interview-worthy problem: you have 50 API gateway/LB instances behind a load balancer, and the limit is "1000 req/min per API key" globally. No single instance sees all the traffic. How do you enforce a global limit?
4.1The Core Problem#
If each of 50 instances independently enforces "1000/min," a client can do 50 × 1000 = 50,000/min — 50× the intended limit. You need shared state that all instances consult.
4.2Solution A: Centralized Counter in Redis (the standard answer)#
GET then INCR is a race two servers can both pass.Keep the counters in a shared Redis (from File 02). Every instance does its increment/check against Redis, so all instances share one source of truth.
Naive (racy) version:
count = GET key
if count < limit: INCR key; allow
else: rejectThis has a race condition: two instances both GET 999, both think "under 1000," both INCR → 1001. Over-limit.
Correct version — atomic Lua script: Run the read-check-increment as a single atomic operation on Redis (Redis executes Lua scripts atomically, single-threaded):
-- atomically: increment, set expiry on first hit, return new count
local current = redis.call("INCR", KEYS[1])
if current == 1 then redis.call("EXPIRE", KEYS[1], window) end
if current > limit then return 0 else return 1 endBecause Redis runs it atomically, no two instances can race. This is the canonical distributed fixed-window limiter. Token bucket and sliding window are likewise implementable as atomic Lua scripts (compute-refill-and-spend in one shot).
- Pros: Simple, accurate, one obvious source of truth, Redis is fast (100k+ ops/s).
- Cons: Every request now makes a network call to Redis (adds ~0.5ms latency and load). Redis becomes a potential SPOF / bottleneck — must be made HA (replication, cluster) and may need sharding by key. A hot key (one giant tenant) concentrates on one Redis shard.
4.3Solution B: Local Token Buckets + Async Sync (the scalable approximation)#
To avoid a Redis call on every request, each instance keeps a local bucket and periodically syncs/reconciles with a central store (or gossips with peers). Each instance is allocated a share of the global budget and adjusts based on observed global usage.
- Pros: Most requests are decided locally (no per-request network hop) → low latency, high throughput. Survives Redis blips.
- Cons: Approximate — brief over/under-shooting near limits because sync lags. Complex. Used when you can tolerate a little slop for big performance wins.
4.4Solution C: Sticky Routing#
Route all requests for a given key to the same instance (consistent hashing by API key at the LB — File 06). Then each instance owns a disjoint set of keys and can limit locally with no shared state.
- Pros: No shared counter needed; local speed with global correctness (per key).
- Cons: Requires sticky/consistent routing; rebalancing on scale events; a hot key still overloads its one owner.
4.5The Fundamental Trade-off (CAP-flavored, foreshadowing File 08)#
Distributed rate limiting forces a choice: accuracy vs. latency/availability.
- Perfectly accurate global limit → centralized synchronous check → higher latency, Redis dependency.
- Low-latency, highly-available → local approximation → occasionally lets a bit too much (or too little) through.
Most large systems (Stripe, Cloudflare) choose approximate + fast, accepting small overshoot. State this trade-off explicitly in interviews.
Fail-open vs fail-closed: if the Redis/limiter store is down, do you allow all requests (fail open — prioritize availability, risk overload) or reject all (fail closed — prioritize protection, risk outage)? For public APIs, usually fail open (don't let the limiter itself cause an outage). For abuse/security-critical limits (login), sometimes fail closed. A deliberate choice, not an accident.
4.6Solution D: Batched / Probabilistic Reservation (how you cut the Redis hop without losing much accuracy)#
Between "check Redis every request" (§4.2) and "sync occasionally" (§4.3) sits the approach most high-volume systems actually use: acquire permits in batches.
Mechanism. Instead of decrementing the shared counter by 1 per request, an instance atomically reserves a block of N permits from Redis (say 20), spends them locally with zero network cost, and returns for another block when it runs out. Redis traffic drops by a factor of N immediately.
The trade-off is precise and worth stating in exactly these terms: with M instances each holding up to N unspent permits, the worst-case overshoot is M × N requests beyond the limit — because an instance can hold permits it never spends while others are being refused. So batch size is a directly tunable accuracy-versus-load dial: N = 1 is §4.2 (exact, expensive), large N is §4.3 (cheap, sloppy). The refinement: make N adaptive — a client sending 10,000 req/s gets large batches (the relative error is negligible), while a client sending 2 req/s gets N = 1 (exact, and the Redis load is trivial anyway). This gives you accuracy exactly where it is cheap and approximation exactly where it is needed.
Unspent permits must expire, or an instance that goes idle (or is removed from rotation) permanently removes those permits from the global budget — a slow leak that shrinks your effective limit over time. Same failure shape as the leaked semaphore permit in §3.7.
4.7The Practical Concerns That Decide Whether Your Distributed Limiter Works#
Four operational realities that the three-solutions framing above glosses over.
**The limiter's Redis must be sized for every request, not for cache traffic. A cache absorbs reads; a limiter is consulted on 100% of requests including the ones it rejects. At 50,000 req/s your limiter Redis is doing 50,000 ops/s minimum — well within Redis's capability, but it means the limiter's store is now on the critical path of literally everything. Give it its own instance.** Sharing it with your cache means a cache incident (a big-key DEL, a KEYS scan — File 02 §2.7/§2.8) blocks your limiter, and a blocked limiter with a fail-closed policy is a total outage.
Hot keys apply here exactly as in File 02 §2.8. One enormous tenant means one key means one shard, and that shard receives that tenant's entire traffic. Mitigations, in order: a local pre-filter (the batching of §4.6 collapses a whale's traffic into occasional block reservations), splitting the whale's key into tenant:whale:shard0…shardN with each instance choosing one and each shard holding 1/N of the budget, or giving the largest tenants dedicated limiter capacity.
Latency budget. A limiter check that adds 0.5 ms to every request is usually fine; one that adds 5 ms because the Redis is cross-AZ or the connection pool is undersized is not. Two specifics: keep the limiter store in the same availability zone as the callers where possible (cross-AZ round trips are ~1 ms rather than ~0.2 ms), and always set an aggressive timeout on the limiter call itself — 10–50 ms — with fail-open on timeout. Without that timeout, a slow Redis converts into slow requests for everyone, which is the queueing spiral of §0.5 triggered by your own protective machinery.
Fail-open, done properly. "Fail open" does not mean "no limiting." It means fall back to a local limit, set generously (say 2–5× the per-instance share of the global limit), so that during a limiter-store outage you are still protected against the extreme case while not rejecting legitimate traffic. Pair it with an alarm, because a silently fail-open limiter can stay broken for weeks — the system looks perfectly healthy right up until someone abuses it. The interview line: "fail open to a degraded local limit, never fail open to unlimited."
5Where to Enforce & How to Respond#
5.1Placement (defense in depth)#
- Edge / CDN (Cloudflare, AWS WAF): stop volumetric abuse before it enters your network. Cheapest place to drop junk. (A WAF — Web Application Firewall — is an L7 filter that inspects HTTP requests against rules — known attack signatures like SQL injection payloads, bad bots, geo-blocks — and drops matches before they reach your app; rate limiting is one of its rule types.)
- API Gateway / Load Balancer (File 01): the standard central enforcement point — every request already passes through it, and it's before your expensive app logic.
- Service mesh sidecar (Envoy, File 10): per-service limits between microservices (protect service B from service A).
- Application layer: fine-grained business limits ("3 password resets per day per account") that require app context.
Layer them: coarse volumetric limits at the edge, per-key limits at the gateway, business limits in the app.
5.2Responding Well#
- Return 429 with
Retry-AfterandX-RateLimit-*headers (2.4) so clients back off correctly. - Prefer shed cheaply: reject as early and as cheaply as possible (don't do expensive auth/DB work for a request you'll drop).
- Consider priority/load shedding: under stress, drop low-priority traffic (analytics beacons) before high-priority (checkout).
- Never let the limiter's rejection path be more expensive than serving — attackers would target that.
6Pros, Cons & Trade-offs#
6.1Benefits#
- Stability: sheds excess load, prevents cascading collapse (a bulkhead).
- Fairness: stops noisy neighbors; guarantees per-tenant slices.
- Security: blunts brute force, scraping, app-layer DoS.
- Cost & monetization: caps spend; enforces pricing tiers.
6.2Costs & Trade-offs#
- Latency/overhead: distributed limiting adds a per-request check (Redis hop).
- Accuracy vs performance: centralized (accurate, slower) vs local (fast, approximate).
- SPOF risk: the limiter store must be HA; fail-open/closed decision matters.
- False positives: legit users behind a shared NAT/IP hit IP-based limits unfairly; a burst of legit traffic (flash sale) gets throttled. Tuning is hard.
- Complexity: choosing keys, algorithms, windows, and syncing state across a fleet.
- Client friction: too-tight limits frustrate real users and partners.
7Interview Diagnostic Framework (Symptom → Solution)#
| System Symptom | Why rate limiting is the cure | Specific solution |
|---|---|---|
| "One client's traffic spike takes down the API for everyone." | Need to shed excess before collapse. | Per-key limit at the gateway (token bucket / sliding window). |
| "Brute-force login attempts / credential stuffing." | Throttle abusive auth attempts. | Tight per-account + per-IP limits on /login, fail-closed. |
| "We need to allow bursts but cap the average rate." | Model avg + burst. | Token bucket (B = burst, r = sustained). |
| "A fragile downstream needs a perfectly steady request rate." | Smooth bursty input. | Leaky bucket (constant drain). |
| "Fixed-window lets clients double their rate at the boundary." | Boundary burst bug. | Sliding window counter. |
| "We have 50 gateway nodes; the global limit isn't enforced." | No shared state. | Centralized Redis counter with atomic Lua (or local+sync). |
| "Free vs paid tiers need different quotas." | Monetization boundary. | Per-API-key limits keyed by plan. |
| "Scrapers rotate IPs to evade limits." | IP key is evadable. | Move to API-key/account key + edge/WAF fingerprinting. |
| "Our third-party API bill is exploding." | Uncapped downstream calls. | Global limit / concurrency cap on outbound calls. |
8Real Implementations & Product Landscape#
You will almost never write a rate limiter from scratch — you will configure one. This section is what exists, which algorithm each one actually implements (they differ, and the difference is visible in your traffic), and which to choose.
8.1Family 1 — Edge and CDN limiters#
Cloudflare Rate Limiting. Enforced at hundreds of PoPs before traffic reaches your network, which is the cheapest possible place to drop junk — you pay nothing in origin capacity for a request Cloudflare rejected. It implements a sliding window counter (§3.3) with local counting plus asynchronous cross-PoP reconciliation, which is the §4.3 approximation applied at planetary scale: a client hitting five PoPs is counted approximately, not exactly, because a synchronous global count would add cross-ocean latency to every request. Rules can key on IP, header, cookie, query parameter, or JA3/JA4 TLS fingerprint (§2.8), and can act with block, challenge (CAPTCHA/JS), or log-only — that last one being how you safely roll out a new limit (§2.2's soft limit). Limits: approximate global counts (deliberately); you are trusting a third party with your traffic. Reach for it when you face volumetric abuse, scrapers, or credential stuffing — anything you want stopped before your bill starts.
AWS WAF rate-based rules count requests per IP (or per header/cookie via custom aggregation keys) over a fixed 5-minute sliding evaluation, with a coarse evaluation granularity — it is designed for volumetric defence rather than precise per-second limits. Fastly exposes rate limiting through VCL/Compute with edge-local counters. Akamai offers similar controls. The universal trade at the edge: cheapest enforcement, coarsest accuracy, and no application context — the edge does not know that this user is on the enterprise plan.
8.2Family 2 — Reverse proxies and load balancers#
**NGINX limit_req. Important detail almost everyone gets wrong: limit_req implements a leaky bucket, not a token bucket.** Without the burst parameter it enforces a strictly smooth rate and rejects anything arriving early — which is why naïvely setting rate=10r/s and then seeing legitimate page loads rejected is such a common surprise (a browser firing 10 parallel asset requests in 50 ms is not 10 requests per second by NGINX's reckoning). Adding burst=20 gives a queue of 20 that is drained at the configured rate — the leaky-bucket-as-queue of §3.5, with real added latency. Adding nodelay serves the burst immediately while still consuming queue slots, which converts it into token-bucket-like behaviour. **limit_conn is the separate directive implementing a concurrency limit (§3.7). Counters live in a shared memory zone — per NGINX instance, so with N load balancers your effective limit is N × the configured value (§4.1). Reach for it when you already run NGINX and want a solid local limiter; not when** you need one global count across instances.
HAProxy stick tables. HAProxy's mechanism is unusually powerful: a stick table is an in-memory table keyed by anything you can extract from a request (source IP, header, URL parameter), storing counters, rates, and error counts, with built-in peer replication — HAProxy instances synchronize their stick tables with each other over a peers protocol. That means you get approximately global counting with no external datastore at all, which is the §4.3 local-plus-sync design shipped as a feature. It also tracks derived signals like HTTP error rate per key, so you can limit clients that generate errors rather than merely clients that are busy. Reach for it when you run HAProxy and want distributed-ish limiting without adding Redis.
Envoy offers both: a local rate limit filter (token bucket, per-instance, zero latency) and a global rate limit service — Envoy calls out over gRPC to an external service (the reference implementation, envoyproxy/ratelimit, is Redis-backed) on each request. The two are designed to be layered: local first to cheaply shed obvious excess, global second for accurate cross-fleet limits. Envoy also implements retry budgets and circuit breaking (§0.3, §2.5) in the same data plane, which is a genuine architectural advantage — the limiter and the retry policy are configured together rather than in two unrelated systems. This is the model to describe in an interview when asked how to do it properly in a microservice environment.
8.3Family 3 — API gateways#
Kong ships two plugins whose difference is the entire lesson: rate-limiting (fixed window, counters in a local store, Redis, or Postgres) and rate-limiting-advanced (sliding window, Redis-backed, with more key options). Choosing the basic one and then being surprised by the 2× boundary burst is a real, common outcome (§3.1). AWS API Gateway exposes a token bucket directly — rate is the refill rate and burst the bucket size (§9.3) — applied at account, stage, method, and per-API-key levels through usage plans, which is how you implement tiered pricing without writing code. Apigee, Tyk, and Azure API Management offer comparable quota-plus-spike-arrest pairs; note that Apigee's "Quota" and "Spike Arrest" policies are precisely the §2.6 quota-versus-rate distinction exposed as two separate policies, which is a well-designed API worth citing.
API gateways are where tiering lives, because they are the layer that already knows which API key maps to which plan. Doing this at the edge is impossible (no plan context) and doing it in the application is late (you have already paid for auth and routing).
8.4Family 4 — Datastore-backed limiters you build#
Redis + Lua is the canonical build-it-yourself answer (§4.2), and the reason is atomicity (§0.4). Notable ready-made pieces: **redis-cell, a Redis module implementing GCRA** (§3.6) as a single CL.THROTTLE command that returns allowed/denied, remaining quota, and the exact retry-after — the cleanest primitive available; and the widely-copied sorted-set sliding-window-log script. Redis Cluster caveat: a limiter key and its Lua script must resolve to one slot, so multi-key scripts need hash tags (File 02 §10.1).
Language libraries worth knowing by name, because interviewers ask what you would actually use: Bucket4j (Java, token bucket, pluggable backends including Redis/Hazelcast), **Guava RateLimiter (Java, token bucket, single-JVM only, with a warm-up mode that ramps the permitted rate after idleness), resilience4j (Java, rate limiter + bulkhead + circuit breaker in one library — the modern replacement for Hystrix), golang.org/x/time/rate (Go, token bucket, standard-library-adjacent and excellent), Netflix concurrency-limits (adaptive, §3.8), and in Python limits or Django/Flask middlewares. The universal caveat:** all of these are per-process unless explicitly backed by a shared store — a per-process limiter on a 50-instance fleet enforces 50× your intended limit (§4.1).
8.5Comparison table#
| Product | Algorithm | Scope | Latency added | Distributed accuracy | Tiering support | Best for |
|---|---|---|---|---|---|---|
| Cloudflare | Sliding window counter | Global edge | ~0 (already inline) | Approximate (async reconcile) | Limited | Volumetric abuse, scrapers, DDoS |
| AWS WAF | Fixed/rolling 5-min | Edge / ALB | ~0 | Approximate | ❌ | Coarse IP-based volumetric defence |
**NGINX limit_req** | Leaky bucket (+nodelay ≈ token) | Per instance | ~0 | None — per instance | ❌ | Local protection where NGINX already runs |
| HAProxy stick tables | Counters + rates | Per instance + peer sync | ~0 | Approximate, no external store | ❌ | Distributed-ish limiting with no Redis |
| Envoy local | Token bucket | Per instance | ~0 | None | ❌ | Cheap first layer |
| Envoy + ratelimit svc | Configurable, Redis-backed | Global | ~0.5–1 ms | Accurate | Via descriptors | Microservices done properly |
**Kong rate-limiting** | Fixed window | Global (Redis) | ~1 ms | Accurate but 2× boundary | ✅ | Simple gateway limits |
| Kong advanced | Sliding window | Global (Redis) | ~1 ms | Accurate | ✅ | Gateway limits without the boundary bug |
| AWS API Gateway | Token bucket (rate + burst) | Managed global | ~0 | Managed | ✅ Usage plans | Monetized APIs on AWS |
| Apigee / Azure APIM | Quota + spike arrest | Managed global | ~1 ms | Accurate | ✅ | Enterprise API programs |
| Redis + Lua (DIY) | Anything you write | Global | ~0.5 ms | Exact | You build it | Full control, custom keys/costs |
**redis-cell** | GCRA | Global | ~0.5 ms | Exact | You build it | Minimal state + exact Retry-After |
**Bucket4j / x/time/rate** | Token bucket | Per process (or Redis) | ~0 local | Depends on backend | You build it | In-app business limits |
| Netflix concurrency-limits | Adaptive AIMD | Per process | ~0 | n/a | ❌ | Self-tuning overload protection |
8.6Decision rules#
- Layer, do not choose. The correct production answer is all of: coarse volumetric limits at the edge (Cloudflare/WAF), per-key limits at the gateway (where plan context lives), local token buckets in the mesh (Envoy) for service-to-service, and business limits in application code ("3 password resets per day"). Each layer catches what the one outside it cannot see.
- Choose the edge when the traffic is not yours — attackers, scrapers, bots. Every request rejected at the edge costs you nothing.
- Choose the API gateway when the limit is a product boundary — free versus pro tiers, per-customer quotas — because that is the only layer holding both the API key and its plan.
- Choose Redis + Lua when your keying or cost model is unusual (§2.7's weighted limiting, multi-dimensional composite keys) and no product expresses it. Use
redis-cellif plain GCRA suffices. - Choose HAProxy stick tables over a Redis-backed limiter when you want approximately-global counting and adding a Redis dependency (a new SPOF, §4.2) is worse than accepting approximation.
- Add an adaptive concurrency limiter internally regardless of what you chose externally. The external limits protect you from customers; the adaptive limit protects you from yourself.
8.7Anti-recommendations#
- A per-process limiter presented as a global one. Guava's
RateLimiter, NGINX'slimit_req, and Envoy's local filter are all per-instance. On a 50-node fleet you have configured 50× your intended limit and you will not notice until an incident (§4.1). - Rate limiting on IP alone in 2026. Residential proxy services sell millions of real residential IPs cheaply; IPv6 gives every customer a /64 (§2.8). IP is a speed bump, not a limit.
- **Trusting
X-Forwarded-Forwithout a trusted-proxy configuration.** Covered in §2.8 — this is a bypass, and it also lets an attacker get your users blocked. - Storing billing quotas in a cache with eviction enabled. A quota is durable accounting (§2.6); an evicted counter is lost revenue or a free-tier customer with unlimited access.
- Limiting requests when the resource is tokens, bytes, or CPU-seconds. For LLM APIs, exports, and search, request counting is nearly meaningless — limit the thing that actually costs you money (§2.7).
- A fail-closed limiter on a critical path with no local fallback. If Redis blips and your limiter rejects everything, you caused the outage the limiter existed to prevent (§4.5). Decide fail-open/fail-closed per endpoint, in advance, and keep a degraded local limit for when the shared store is unreachable.
9Real-World Engineering Scenarios#
9.1Stripe — Fair, Tiered API Rate Limiting#
Stripe (publicly documented) protects its API with multiple limiter types layered together:
- A request rate limiter (token-bucket-style) caps requests per second per account.
- A concurrency limiter caps simultaneous in-flight requests (protects against a few very slow requests hogging capacity).
- Load shedders reserve capacity for critical traffic: under stress, non-critical requests (e.g., background/testing) are shed first so that live-mode payment traffic keeps flowing.
- Limits are enforced centrally (Redis-backed) with atomic operations, and responses carry
429+ informative headers so integrators back off. - Takeaway: production rate limiting is several coordinated limiters (rate + concurrency + priority load shedding), not one, and it protects the most important traffic first.
9.2Cloudflare — Edge Rate Limiting at Global Scale#
Cloudflare enforces limits at its edge across hundreds of PoPs (points of presence):
- Because traffic for one customer can hit many PoPs, Cloudflare uses a distributed counting approach — each edge node counts locally and reconciles counts across the network so the global limit is approximately enforced with minimal latency.
- They favor the sliding window counter algorithm (current+previous weighted) for its excellent accuracy-to-memory ratio at their volume.
- Limiting happens before traffic reaches the origin, absorbing volumetric DDoS at the edge (defense in depth with their WAF).
- Takeaway: at planetary scale you accept approximate global counts (local count + async reconciliation) to keep latency low, and you enforce as far toward the edge as possible.
9.3AWS API Gateway — Token Bucket as a Managed Service#
AWS API Gateway exposes rate limiting directly as token bucket parameters:
- Rate = steady-state token refill rate (req/s); Burst = bucket size (max tokens) = how big a spike it tolerates.
- Limits apply at multiple scopes: account-level, per-API-stage, and per-client via usage plans + API keys (monetization tiers).
- Exceeding the bucket returns
429 Too Many Requests; clients are expected to back off with jitter. - Takeaway: the token-bucket (rate, burst) pair is the industry-standard interface for expressing a rate limit because it cleanly separates sustained rate from burst tolerance.
10Interview Gotchas & Failure Modes#
10.1Classic Questions#
- "Token bucket vs leaky bucket?" — Token bucket allows bursts up to bucket size then enforces average rate; leaky bucket forces a constant smooth output with no bursts (shaping). Pick token bucket for "avg + burst," leaky for "protect a downstream that needs steady flow."
- "Design a distributed rate limiter." — Centralized Redis counter with an atomic Lua script (avoid the read-check-increment race); discuss the latency cost, HA/sharding of Redis, hot-key problem, and the accuracy-vs-latency trade-off (centralized vs local+sync). Mention fail-open vs fail-closed.
- "What's wrong with fixed window?" — The boundary burst (2× the rate across a reset). Fix with sliding window counter.
- "What key do you limit on?" — Depends: IP (easy, unfair/evadable), user/API key (fair, needs auth), composite. Discuss NAT and IP-rotation.
- "What happens when the limiter's datastore is down?" — Fail-open (availability, risk overload) vs fail-closed (protection, risk outage) — a deliberate trade-off by endpoint criticality.
10.2Failure Modes & Mitigations#
- Race conditions in the counter → non-atomic read-modify-write lets limits leak; fix with atomic ops (Lua/
INCR). - Redis/limiter store down → decide fail-open vs fail-closed in advance; add local fallback limits so you're never fully unprotected.
- Hot key → one huge tenant concentrates all its limiting on a single Redis shard → that shard melts. Mitigate with local pre-filtering, key sharding, or dedicated capacity for whales.
- Retry storms → clients that retry 429s immediately amplify overload; require exponential backoff + jitter and send
Retry-After. - Clock skew (in distributed sliding-window/log) → nodes disagree on "now," corrupting window math. Use a single time source or the datastore's clock.
- False positives under legit spikes (flash sale, viral moment) → good traffic throttled; mitigate with burst headroom, per-user (not per-IP) keys, and dynamic/adaptive limits.
- Limiter becomes the bottleneck → the check costs more than serving; keep the reject path cheap and enforce early/at the edge.
11Whiteboard Cheat Sheet#
Client ──► [Edge/WAF limit] ──► [API Gateway/LB limit] ──► [Service mesh limit] ──► App
(volumetric) (per-key, main) (svc-to-svc) (business rules)
ALGORITHMS:
Fixed Window ......... counter/window; simple; 2x boundary burst BUG
Sliding Log .......... store every timestamp; perfect; memory-heavy
Sliding Window Ctr ... cur+prev weighted; O(1); BEST DEFAULT
Token Bucket ......... capacity B(burst) + refill r(rate); allows bursts; MOST POPULAR
Leaky Bucket ......... FIFO drained at constant r; smooth output; no bursts; adds latency
(two forms: QUEUE = shaper, adds latency | METER = policer ≡ token bucket)
GCRA ................. one timestamp (TAT); token bucket w/ minimal state; EXACT Retry-After
Concurrency (semaphore) in-flight cap; bounds OCCUPANCY not arrival; MUST release in finally
Adaptive (AIMD) ...... limit itself is variable; +1 healthy / ×0.5 congested; zero config
LIMIT TYPES (do not conflate):
Rate = requests per second -> protects throughput -> algorithms above
Quota = total per month/day -> billing; must be DURABLE, never in an evicting cache
Concurrency = in-flight at once -> protects POOLS; the one people forget
WHY IT COLLAPSES: wait ≈ service × ρ/(1−ρ). 95%->99% util = 5× slower. Run at 60-70%.
Little's Law: L = λ × W (1000/s × 0.2s = 200 in flight = 200 threads)
DISTRIBUTED (N nodes, global limit):
Redis + atomic Lua (INCR/EXPIRE) -> accurate, +0.5ms/req, make Redis HA
Local buckets + async sync -> fast/approximate, small overshoot
Consistent-hash sticky by key -> local limit, per-key correct
Trade-off: ACCURACY vs LATENCY. Fail-open (avail) vs fail-closed (protection).
RESPONSE: 429 + Retry-After + X-RateLimit-{Limit,Remaining,Reset}. Client: backoff+jitter.One-sentence summary for an interviewer:
"Rate limiting protects finite backends from unbounded demand by capping requests per key; use token bucket when you need average-rate-plus-burst, leaky bucket to shape a smooth output, and sliding-window counter as the accurate O(1) default; enforce a global limit across many nodes with an atomic Redis counter (accepting a latency cost) or local buckets with async sync (accepting small overshoot), and decide fail-open vs fail-closed deliberately."
End of File 03. Next → File 04: Database Sharding & Partitioning (horizontal scaling, shard keys, resharding, hot spots).