Compare fixed window, sliding window, token bucket and leaky bucket limiters, then design a distributed rate limiter that is fair, fast and safe to run.
What you'll learn: By the end of this lesson you will be able to choose between the four classic rate limiting algorithms, design a limiter that works across many servers using atomic operations in a shared store, decide where in the stack to enforce limits, and return responses that help well-behaved clients recover.
Think of a nightclub with a doorman. The club holds a fixed number of people, so the doorman lets guests in at a steady pace, and sometimes lets a small group in together if the room is quiet. If someone tries to push through, the doorman politely says "come back in ten minutes". He is not punishing anyone. He is protecting the people already inside.
A rate limiter is that doorman for your API. It caps how many requests a caller can make in a period of time, so that one noisy client, a buggy retry loop or an attacker cannot use up capacity that everyone else is relying on.
The core idea
Rate limiting is a decision made on every request: allow or reject? To decide, the limiter needs three things:
A key that identifies who is being limited: user id, API key, IP address, tenant, or a combination such as user:42 + endpoint:/search.
A rule: for example "100 requests per minute" or "5 login attempts per 15 minutes".
State: a counter, timestamp list or bucket level for each key, kept somewhere fast.
Why bother? Several different reasons share one mechanism:
Protect shared capacity so one tenant cannot starve others (the "noisy neighbour" problem).
Contain accidents, such as a client script stuck in a retry loop.
Resist abuse: credential stuffing (trying many stolen passwords), scraping, spam.
Control cost, especially when each request triggers an expensive downstream call such as a paid model API.
Enforce commercial tiers, such as free versus paid quotas.
Rate limiting differs from related ideas. Load shedding drops work when the server is overloaded regardless of who sent it. Back-pressure slows producers when consumers are behind. A rate limiter enforces a per-caller policy, even when the system is healthy.
The four classic algorithms
Fixed window counter
Divide time into fixed windows (for example each calendar minute). Keep one counter per key per window. Increment on each request; reject if the counter exceeds the limit; the counter resets when the next window starts.
It is very cheap: one counter per key. Its flaw is the boundary burst. With a limit of 100 per minute, a client can send 100 requests at 12:00:59 and another 100 at 12:01:00. That is 200 requests in two seconds, yet each window saw only 100.
Sliding window log
Store a timestamp for every accepted request. On each new request, discard timestamps older than the window, then count what remains. If the count is below the limit, accept and record the timestamp.
It is exact, with no boundary burst. The cost is memory: you store up to limit timestamps per key, which is expensive if limits are large and keys are many.
Sliding window counter
A compromise. Keep counters for the current and the previous fixed window, and estimate the rolling count by weighting the previous window by how much of it still overlaps the sliding window.
For illustration, assume a limit of 100 per minute, the previous minute had 80 requests, the current minute has 30 so far, and we are 25 percent into the current minute. The estimate is 80 x 0.75 + 30 = 90, so the request is allowed. This assumes traffic was spread evenly within the previous window, so it is approximate, but it needs only two counters per key and smooths the boundary burst.
Token bucket
Imagine a bucket that holds up to capacity tokens. Tokens are added at a steady refill rate. Each request takes one token (or more for expensive requests). If the bucket is empty, the request is rejected.
This permits bursts up to the bucket capacity while holding the long-run average to the refill rate. A bucket of 20 tokens refilled at 5 per second allows a burst of 20 immediately, then a sustained 5 per second. It needs only two numbers per key: the token count and the last refill time. Refill is computed lazily on each request rather than by a timer.
Leaky bucket
Picture a bucket with a small hole. Requests pour in at any rate; they drain out at a constant rate. If the bucket overflows, new requests are dropped. In a queue-based form, accepted requests wait in a bounded queue and are processed at a fixed rate, producing a perfectly smooth output.
It is ideal when a downstream system needs a steady rate, but bursts become latency (waiting in the queue) rather than being passed through, and old requests in a full queue can delay fresh ones.
NOTE
A leaky bucket used as a meter (reject when full) behaves much like a token bucket in practice. The usual distinction is the shape of the output: token bucket allows bursts through, queue-based leaky bucket smooths them out.
Comparison
Algorithm
State per key
Allows bursts
Accuracy
Main drawback
Good fit
Fixed window
One counter
Yes, and double bursts at boundaries
Low at edges
Boundary spike
Coarse quotas, daily limits
Sliding window log
A timestamp per request
No
Exact
Memory grows with limit
Small limits needing precision, such as login attempts
Sliding window counter
Two counters
Slightly
Approximate
Assumes even distribution
General API limits at scale
Token bucket
Tokens plus last-refill time
Yes, up to capacity
Good
Burst size must be tuned
Public APIs that tolerate short bursts
Leaky bucket
Queue or level
No, smoothed
Good
Added latency, queue management
Protecting a fixed-rate downstream
If you are unsure which to pick, this flowchart gives a sensible default.
How to read it: most public APIs end at the token bucket. Fixed windows suit quotas where a boundary burst does not matter, and the exact log suits tiny limits such as login attempts.
A token bucket sketch
Here is a single-process token bucket in TypeScript. It shows the lazy refill idea; a distributed version needs atomicity, covered next.
The bucket moves between three conditions as traffic and time pass.
stateDiagram-v2
[*] --> Full
Full --> Partial: request spends tokens
Partial --> Partial: refill adds tokens
Partial --> Full: refill reaches capacity
Partial --> Empty: tokens run out
Empty --> Partial: refill adds a token
Empty --> Empty: request rejected with 429
Note the cost parameter: a search request might cost 5 tokens while a health ping costs 1, so the limit tracks real load, not just request count. Also note that this map grows forever as new keys arrive. A production version evicts idle keys (a bucket that has refilled completely is equivalent to a new one and can be deleted).
Distributed rate limiting
One server with an in-memory map is easy. Real systems run many gateway or application instances behind a load balancer. If each instance limits independently, a client hitting ten instances effectively gets ten times the limit, and the exact multiple depends on load balancer behaviour.
There are three common designs.
Design
How it works
Cost
Choose when
Local limits
Each instance enforces limit divided by instance count
No network call; inaccurate as instances scale or traffic is uneven
Rough protection, very high request rates
Shared store
All instances read and update state in a central store such as Redis
One network round trip per request; store is a dependency
Accurate per-key limits
Sticky routing
Route each key to the same instance (consistent hashing) so its state is local
Complex routing; hot keys overload one node; failover loses state
Very large scale where store traffic is the bottleneck
The shared store design is the most common. The hazard is a race condition. Suppose two instances both read a counter of 99, both decide "under the limit of 100", and both write 100. Two requests passed but only one was counted. At high concurrency the error grows.
The fix is atomic operations: the read-modify-write must happen as one indivisible step in the store. Options include an atomic increment command (enough for fixed windows), or a short server-side script (Redis supports Lua scripts that run atomically) that does the whole token bucket calculation in one call. The script reads the bucket, refills it, decides, writes back and returns the result, with no other client able to interleave.
sequenceDiagram
participant C as Client
participant G1 as Gateway A
participant G2 as Gateway B
participant R as Shared store
C->>G1: request 1
G1->>R: atomic check and decrement
R-->>G1: allowed, last token used
C->>G2: request 2
G2->>R: atomic check and decrement
R-->>G2: denied, bucket empty
G2-->>C: 429 with Retry-After
Some design decisions deserve thought:
Latency. The limiter sits on the hot path of every request. Keep the store close (same region, same zone if possible) and the operation to one round trip.
Fail open or fail closed? If the store is unreachable, do you allow all requests or reject all? Failing open keeps the product alive but removes protection; failing closed protects the backend but turns a limiter outage into a full outage. Many teams fail open for general API limits and fail closed for security-sensitive limits such as login attempts. Decide and document it.
Expiry. Set a time-to-live on each key so idle callers do not accumulate state forever.
Hot keys. A single very popular key (one huge tenant) concentrates traffic on one store shard. Consider splitting its limit across several sub-keys.
Clock skew. If instances compute timestamps themselves, clock differences distort refill. Prefer using the store's clock inside the script.
Batching. To cut store traffic, an instance may reserve a chunk of tokens at once and spend them locally. This trades a little accuracy for far fewer round trips.
Where to enforce limits
You can rate limit at several layers, and mature systems usually use more than one.
Knows business context: per-user, per-action, per-cost
Duplicated across services; must be consistent
Downstream protection
A limiter in front of a database or paid API protects that dependency
Does not stop wasted work upstream
A good pattern: coarse IP-based limits at the edge to absorb floods, per-key plan limits at the gateway, and special limits inside services for expensive or sensitive actions such as password reset or report export.
What to return when you reject
Use HTTP status 429 Too Many Requests. Tell the client when to come back with the Retry-After header, either as a number of seconds or an HTTP date. Many APIs also expose the current state with headers such as limit, remaining and reset time. A standard for these is still evolving, so conventions vary between providers; pick names, document them and keep them consistent (see API design).
A helpful error body names the limit that was hit, for example "per-minute request limit", so the developer knows whether to slow down or upgrade a plan.
Well-behaved clients respect Retry-After, and add jitter (randomness) to their backoff so that thousands of rejected clients do not all retry at the same instant. See reliability basics.
TIP
Rejecting a request is cheap, but it is not free: the request still arrived, was parsed and was checked. A limiter protects your application and database, not your network. Against a large flood, you need limits at the edge.
Fairness, tiers and identity
Choosing the key is as important as choosing the algorithm.
IP address is easy but unreliable. Many users share one address behind corporate or mobile carrier networks, so strict per-IP limits punish innocent people, while attackers rotate addresses.
API key or user id is accurate for authenticated traffic. Use it as soon as you know it.
Tenant limits stop one customer from consuming the whole pool.
Composite keys (user plus endpoint) allow different limits for cheap reads and costly writes.
Common fairness patterns: tiered plans looked up per key, a short-window limit for bursts plus a long-window quota for sustained use, reserved capacity for critical traffic such as payment callbacks, and a global ceiling alongside per-key limits so the ceiling is shared fairly.
In practice: a public geocoding and SMS-verification API with pricing tiers
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A developer-facing API offers address geocoding and an SMS-verification endpoint, sold in three plans: Free, Pro and Enterprise. Eight engineers run it on Kubernetes behind Envoy, with six API pods, a PostgreSQL database holding keys and plans, Prometheus and Grafana, and a paid third-party SMS provider. One engineer is on call at a time.
Today each pod has its own in-memory fixed-window limiter. Three things hurt (all numbers assumed). Effective limits equal the configured limit times the pod count, so a Free key can pull roughly six times its quota. A scraper rotating IP addresses with throwaway Free keys pushes p99 from 120 ms to 900 ms for paying customers. And an SMS-pumping pattern, where abusers trigger verification messages to numbers they profit from, produced a provider bill that is a multiple of the month's forecast. Pod restarts also reset every counter.
Starting architecture
How to read it: limiter state is private to each pod, so nothing coordinates the pods, and the SMS provider is reachable without any spend control.
Options considered
Option
What it fixes
What it costs
Risk
A. Smaller change: divide each limit by the pod count and add IP blocks in the WAF
Rough cap, quickly
Almost nothing
Wrong whenever pod count changes; blocks shared-NAT customers; rotating IPs defeat it
B. Central limiter: token bucket in Redis, run atomically by a Lua script, keyed by API key and route class, with weighted costs
Accurate per-plan limits, weighted cost, shared across pods
A Redis deployment, one round trip per request
Redis becomes a dependency on the hot path
C. Edge limiting only: coarse limits at the CDN or WAF
Floods and crude abuse
Vendor configuration
No knowledge of plans, cost weights or SMS spend
The decision
Field
Entry
Context
Per-pod limits are inaccurate; abuse hurts paying tiers; SMS spend is uncapped
Decision
Option B for plan limits, plus coarse IP limits at the edge from option C. SMS gets extra keys: per destination number, per country and a daily spend cap
Good consequences
Limits are accurate and independent of pod count; abusers on Free keys are isolated from paying tiers; spend is bounded
Bad consequences
One more stateful dependency; about one network round trip of added latency; operators must decide failure behaviour per route
Revisit when
Limiter p99 exceeds 2 ms (assumed), or Redis traffic nears the capacity of one primary and keys must be sharded
Failure behaviour is a design input, not an afterthought: geocoding reads fail open into a per-pod local fallback limiter, while the SMS endpoint fails closed because every message costs money.
Rollout and verification
Run the new limiter in shadow mode for a week: it computes decisions and logs "would reject" but enforces nothing. Compare its verdicts with expectations per plan, then enforce for the Free tier first, behind a feature flag with a kill switch, then Pro and Enterprise. Add Retry-After and limit headers and announce the change to customers before enforcement.
Prove it with these panels: allowed versus rejected requests per plan, top ten keys by rejections, limiter latency p99 against the 2 ms budget, Redis CPU and memory, a counter of fail-open decisions (it should normally be flat at zero), paying-tier p99 during a scrape, and SMS spend per hour with an alert on rapid growth. Before enforcement, load test at about three times the assumed peak with a synthetic abusive key and confirm that other keys keep their latency.
The 2 a.m. failure
The page: "Limiter fail-open count above zero for 2 minutes, API p99 above 500 ms". A Redis primary failed over and the limiter's calls timed out for about 30 seconds (assumed). Geocoding traffic fell back to the per-pod limiters, which had been configured with the full plan limit rather than a share of it, so each key could again draw about six times its quota, and a scraper took the opening.
Limiter failure path when Redis does not answerLimiter call to Answer within 5 ms?; Answer within 5 ms? to Apply decision (yes); Answer within 5 ms? to Route class (no); Route class to Local fallback (geocoding read); Route class to Reject (SMS send)Limiter call: to RedisLimiter callto RedisAnswer within 5 ms?Answer within5 ms?Apply decisionApply decisionRoute classRoute classLocal fallback: per-pod limiterLocal fallbackper-pod limiterReject: 503, Retry-AfterReject503, Retry-Afteryesnogeocoding readSMS send
First three checks:
redis-cli -h <redis-host> INFO replication to confirm which node is the primary and that the replica caught up.
kubectl logs deploy/api --since=10m | grep limiter to see timeouts and fail-open decisions (the deployment name is an assumption).
kubectl top pods -n api to see whether the API pods are saturated.
Mitigation: once Redis is healthy, confirm the fail-open counter returns to zero. If the scraper persists, add its keys to a deny list and its networks to an edge rule. Follow-up fixes: set the local fallback to the plan limit divided by the pod count (and test that setting), use client timeouts shorter than the request deadline, and rehearse a Redis failover in a game day.
Architect's notes
Measure the abuse before you design the limit. Pull a week of per-key request counts and find the real distribution. Limits set from guesses either punish customers or miss the abuser.
Ask who pays for a rejected request. A limiter protects your application and database, not your bandwidth. Cost-bearing endpoints such as SMS need spend caps, not only request rates.
Treat limits as a product. Plans, headers and error messages are customer-facing. Sales and support need a way to see why a key was throttled and to grant a temporary exception.
Name an owner for the limiter fleet. Redis behaviour on the hot path should have an on-call owner and a documented failure mode per route.
Watch the free tier. Account creation with no friction is what lets one abuser mint thousands of keys, so pair limits with signup checks.
Defer sticky routing and local token reservation. Both cut Redis traffic but add complexity; adopt them only when you measure Redis as the bottleneck.
Common mistakes
Using only fixed windows and being surprised by double bursts. Edge spikes hit your backend at twice the intended rate. Use a sliding counter or token bucket when bursts matter.
Non-atomic check-then-increment across servers. Races let extra requests through. Use atomic store operations or scripts.
Limiting only by IP. Shared addresses block legitimate users and attackers rotate IPs. Prefer authenticated identity where available, and combine it with IP for anonymous routes.
Having no plan for limiter outages. Decide fail-open or fail-closed per route, and alert when the limiter store is unhealthy.
Returning 429 without guidance. Clients retry immediately and make things worse. Send Retry-After and document the limits.
Counting every request equally. A cheap lookup and an expensive export are not the same load. Use weighted costs.
Never cleaning up state. Per-key counters pile up forever. Set expirations.
Key takeaways
Rate limiting protects shared capacity. It stops noisy clients, bugs and abuse from harming everyone else.
Fixed windows are cheap but spiky. Boundary bursts can double the intended rate.
Token bucket is the general-purpose default. It allows bounded bursts while enforcing an average rate, with just two numbers per key.
Leaky bucket smooths output. Use it to feed a downstream system that needs a constant rate.
Distributed limits need atomicity. Check and update in one operation, usually a script or atomic command in a shared store.
Decide fail-open or fail-closed explicitly. The right answer differs for general API traffic and security-sensitive routes.
Enforce in layers. Edge for floods, gateway for plans, services for expensive actions.
Tell clients how to behave. Use 429 with Retry-After, and have clients back off with jitter.
Practice exercises
Exercise 1 — Starter (30 min): A limit is 60 requests per minute using a fixed window. Show with a timeline how a client can make 120 requests in under 5 seconds without ever violating the rule. Then show how a sliding window log would reject some of those requests.
Exercise 2 — Intermediate (2 hours): Implement the token bucket from this lesson with capacity 10 and refill 2 per second, then write a test that simulates requests at specific timestamps and verifies bursts, refill and rejection. Extend it with a cost parameter and an eviction rule for fully refilled idle buckets.
Exercise 3 — Advanced (half day): Design a rate limiter for an API with 5 million keys, 50,000 requests per second at peak (assumed) and three plan tiers. Specify the algorithm, state layout, shared store topology, handling of a hot tenant, the behaviour when the store is down for each of three route types, response headers, and the metrics you would alert on. Draw the request path as a sequence diagram.
Lesson 10 of 14 · System Design Building Blocks: Data, Messaging and Scale
Compare fixed window, sliding window, token bucket and leaky bucket limiters, then design a distributed rate limiter that is fair, fast and safe to run.
In the rest of this lesson
A token bucket sketch
Distributed rate limiting
Where to enforce limits
What to return when you reject
Fairness, tiers and identity
In practice: a public geocoding and SMS-verification API with pricing tiers
Enrolling is free. It unlocks every lesson in System Design Building Blocks: Data, Messaging and Scale, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.