Rate Limiting and Throttling: Protecting Systems Under Load
Learn fixed window, sliding window, token bucket, leaky bucket, distributed Redis rate limiting, throttling, quotas, and graceful degradation strategies.
Try it live: See these algorithms in action at the Rate Limiters Simulator — watch token buckets drain, sliding windows shift, and traffic get shaped in real time.
Start With a Black Friday Meltdown
Your e-commerce platform is running a flash sale. Fifty thousand users are refreshing product pages, adding items to cart, and racing to check out.
Then a single scraper — one malicious client — starts hammering the search API at 10,000 requests per second.
There is no rate limiter.
The search service saturates its connection pool. The database CPU hits 100%. The cart service can't fetch inventory. The checkout service can't verify stock. Within 30 seconds, every endpoint is returning 5xx errors. Real customers see spinning spinners. The flash sale becomes a flash outage. Revenue loss: $200,000 in 45 minutes.
A rate limiter would have watched that scraper hit 100 requests in one second — and every subsequent request would have received 429 Too Many Requests while paying customers kept buying.
Rate limiting is not an anti-abuse tool. It is a capacity management tool. Abuse is one use case. Fairness, cost control, and system stability are equally important.
What Rate Limiting Means
Rate limiting controls how many requests a client, user, tenant, IP, or route can send in a given time period. When the limit is exceeded, the system either rejects the request, delays it, or degrades the response.
Rate Limiting vs Throttling vs Quota vs Backpressure
These four concepts are frequently confused. They solve related but distinct problems.
| CONCEPT | WHAT IT DOES | TYPICAL RESPONSE | EXAMPLE |
|---|---|---|---|
| Rate Limiting | Rejects requests above a threshold within a time window | 429 Too Many Requests | Max 100 API calls per minute per API key |
| Throttling | Controls request processing rate by delaying, slowing, or limiting throughput | Increased latency, queueing, reduced throughput, or request rejection | Video streaming bitrate reduction on congested networks |
| Quota | Allows a fixed amount of usage over a longer period (daily/monthly) | 429 Quota Exceeded or Usage Limit Reached | 10,000 LLM tokens per day on a free tier |
| Backpressure | Signals upstream producers to slow down when consumers cannot keep up | Flow-control signals, reduced send rate | Reactive Streams request(n) or TCP flow control |
Rule of thumb: Rate limit by the second or minute. Throttle by the request. Enforce quotas by the day or month. Use backpressure in streaming systems.
Why Not Just Trust Clients to Be Nice?
Beginners often ask: "Why not just assume clients will behave reasonably?"
| Approach | Problem |
|---|---|
| Trust clients | Buggy loops, retry storms, misconfigured scripts, and malicious actors exist |
| Rely on the load balancer | LBs distribute traffic evenly but don't track per-client state |
| Use a single counter | A global counter allows one aggressive client to exhaust the budget for everyone |
| Just reject when CPU is high | By the time CPU is high, the system is already degrading — reactive, not preventive |
A rate limiter makes decisions at the request boundary, before any expensive work happens. It rejects early, rejects cheaply, and protects downstream resources from ever seeing overload.
Reactive protection is too slow. By the time you measure high CPU or connection saturation, the damage is underway. Rate limiting is a preventive control — it shapes traffic at the edge.
Step 1: Choose an Algorithm — Fixed Window Counter
The simplest approach: count requests in fixed calendar windows (e.g., 10:00:00–10:00:59, 10:01:00–10:01:59).
| Pros | Cons |
|---|---|
| Simple to implement — one Redis key per window | Boundary bursts: 100 req at :59 + 100 req at :00 = 200 in 2 seconds |
| Low memory — only a counter and a TTL | Unfair near window boundaries |
| Easy to reason about | Less smooth rate enforcement |
The boundary burst problem: A client can send 100 requests at 10:00:59 and 100 more at 10:01:00 — effectively 200 requests in 2 seconds with no rejection. For some workloads this is acceptable. For others, it defeats the purpose of rate limiting.
Step 2: Sliding Window Log
Store every request timestamp. On each request, remove timestamps older than the window, count remaining ones, and decide.
| Pros | Cons |
|---|---|
| Perfectly accurate — no boundary burst | Stores every timestamp — O(n) memory per client |
| Smooth at any granularity | Higher CPU for cleanup on every request |
| Works for any window length | Expensive at high traffic — 10K req/s × 60s = 600K timestamps in memory |
When to use sliding logs: Precision-critical scenarios where you cannot tolerate bursts. For example, a bidding API where two requests 1ms apart could cost real money.
Step 3: Sliding Window Counter (Approximate)
A hybrid approach: weight the previous window's count by the overlap ratio with the current window. This gives smooth behavior without storing every timestamp.
effective_count =
previous_window_count * overlap_ratio
+ current_window_count
Overlap ratio explained: If the window is 60 seconds and you are 15 seconds into the current window, the overlap with the previous window is (60 - 15) / 60 = 0.75. The previous window's count is multiplied by 0.75 to estimate how many requests from the old window still fall within the sliding window.
| Pros | Cons |
|---|---|
| Smooth — no boundary bursts | Approximate — can be slightly over or under the true count |
| Cheap — only two counters per client | More complex to implement and reason about |
| Good enough for most API gateways | Approximation error grows with uneven traffic distribution |
This is the algorithm behind AWS API Gateway's rate limiting and many production API gateways. It is a strong default for most use cases.
Step 4: Token Bucket
Tokens are added to a bucket at a fixed refill rate. Each request consumes one or more tokens. The bucket has a maximum capacity that limits burst size.
| Parameter | Meaning | Example |
|---|---|---|
| Refill rate | Sustained long-term rate | 10 tokens/second = 36,000 requests/hour |
| Bucket size | Maximum allowed burst | 100 tokens → client can send 100 requests instantly |
| Cost per request | Tokens consumed | 1 for normal, 5 for expensive search, 20 for bulk export |
Burst math: A bucket with 100 capacity and 10 req/s refill allows a client to send 100 requests immediately (the burst), then 10 req/s indefinitely. After draining the bucket, it takes 10 seconds to refill to 100 again.
When token bucket shines: APIs with variable request cost. Give cheap reads a cost of 1, expensive writes a cost of 5, and bulk exports a cost of 50. The same bucket handles all of them fairly.
Step 5: Leaky Bucket
Requests enter a queue and leave at a fixed rate. If the queue is full, excess requests are dropped.
| Pros | Cons |
|---|---|
| Smooth, predictable traffic pattern | Adds latency — requests wait in queue |
| Protects fragile downstream systems | Queue can mask growing overload |
| Easy to model and capacity-plan | Queue depth under burst is hard to tune |
Leaky bucket hides problems. A slowly filling queue means the downstream is keeping up. A growing queue means it is not. The queue provides a buffer, not a fix — monitor queue depth as an early overload signal.
When to use leaky bucket:
- Protecting a legacy database with fixed throughput
- Calling a third-party API with strict rate limits
- Processing pipeline where downstream workers have fixed capacity
- Any system where smoothness matters more than responsiveness
Algorithm Selection Guide
| If you need... | Choose... | Why |
|---|---|---|
| Simplicity, low traffic | Fixed window | One counter, one TTL. Good enough for admin panels |
| Smooth rate, moderate traffic | Token bucket | Bursts allowed, steady long-term. Best all-rounder |
| Precise per-request window | Sliding window log | Every timestamp counted. Expensive but exact |
| Smooth + cheap, high traffic | Sliding window counter | Two counters per key. Approximate but good |
| Downstream protection | Leaky bucket | Forces steady output. Queues burst traffic away from the service |
Step 6: Distributed Rate Limiting
In a horizontally scaled system, each application instance sees only its own traffic. A client could send 100 req/s to instance A and 100 req/s to instance B — both would allow it, even though the total is 200 req/s.
All instances must share rate limit state.
Redis Design
The key insight: increment, expiry check, and decision must be atomic. A race condition between "check count" and "increment count" lets two requests sneak through when only one should.
Atomicity with Lua
A Lua script running on the Redis server keeps the operation atomic: it reads the current counter, checks whether the limit is exceeded, increments if not, and sets the key expiry — all in one uninterruptible execution. This eliminates the race between checking and incrementing. The script returns the remaining count so the application knows exactly how many more requests the client can send. Redis guarantees no other command executes while a Lua script runs, making this the safest approach for distributed rate limiting.
Key Design Decisions
| Concern | Trade-off | Recommendation |
|---|---|---|
| Atomicity | Race on check-then-increment | Always use Lua or INCR with expiry in one call |
| Latency | Every request hits Redis | Keep Redis in the same region; consider local cache fallback |
| Hot keys | One key per user is fine; one key for a popular tenant creates a hotspot | Partition by user, not by tenant ID, for hot tenants |
| Failure mode | Redis goes down — allow all or block all? | Fail open for read APIs, fail closed for write APIs |
| Clock skew | App servers disagree on time | Use Redis TIME command, not local clocks |
Failure mode strategy: Do not use a single strategy for all routes. A payment API should fail closed (reject on Redis failure — better to reject a sale than double-charge). A product listing API can fail open (allow on Redis failure — stale data is better than no data).
Local Caching for Hot Paths
For APIs handling 100K+ req/s per instance, every Redis round trip adds latency. A common optimization: cache the rate limit decision locally for a few hundred milliseconds. The application checks an in-memory cache first — if the TTL has not expired, it returns the cached decision without touching Redis. If the cache misses, it falls through to the Redis Lua script, stores the result back in the local cache with a short TTL (50ms), and returns. This reduces Redis load by an order of magnitude. The trade-off: during the 50ms cache window, the instance may allow up to 5% more requests than configured. For most systems, this is acceptable.
This reduces Redis load by an order of magnitude. The trade-off: during the 50ms cache window, the instance may allow up to 5% more requests than configured. For most systems, this is acceptable.
Limit Dimensions
Rate limits are rarely one-dimensional. Good systems apply layered limits:
| Dimension | What It Protects | Example |
|---|---|---|
| Per IP | Anonymous scraping | 100 req/min per IP for the search API |
| Per user | Authenticated usage fairness | 1000 req/min for free tier, 10,000 for pro |
| Per tenant | Multi-tenant isolation | 5000 req/min per enterprise tenant |
| Per route | Expensive endpoints | 10 req/min for export, 1000 for list |
| Per API key | Developer platform tiers | 1000 req/hour for dev keys, 100,000 for prod |
| Global | Entire service | 1M req/min across all clients — emergency brake |
| Per downstream | Database, third-party APIs | 500 req/s to PostgreSQL, 100 req/s to Stripe |
Order matters. Check cheap limits first (per-IP counter) before expensive ones (per-route database lookup). Fail fast — reject as early as possible in the pipeline.
Step 7: Response Design
A rate-limited client should know exactly what happened and when to retry.
HTTP Status Codes
| Code | When to Use | Meaning |
|---|---|---|
429 Too Many Requests | Client exceeded their rate limit | Back off and retry later |
503 Service Unavailable | Server is overloaded globally | The entire service is under pressure |
Standard Headers
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1717305600
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"message": "API rate limit exceeded. Retry after 30 seconds.",
"retry_after_seconds": 30,
"limit": 100,
"remaining": 0,
"reset_at": 1717305600
}
| Header | Purpose |
|---|---|
Retry-After | Seconds until the client can retry (machine-readable) |
X-RateLimit-Limit | The maximum requests allowed in the window |
X-RateLimit-Remaining | How many requests the client has left |
X-RateLimit-Reset | Unix timestamp when the window resets |
Step 8: Graceful Degradation
When the system is under load, not all features are equally important. Rate limiting should protect critical paths while allowing non-critical features to degrade.
| Overload Situation | Critical Path | Degrade |
|---|---|---|
| Search API under load | Allow search for logged-in users | Serve cached or partial results for anonymous users |
| Analytics dashboard | Keep real-time metrics | Use 5-minute-old stale data |
| Recommendation service | Serve something | Fall back to popular items instead of personalized |
| Notification fanout | Critical alerts (password reset) | Queue promotional emails for later delivery |
| Bulk export endpoint | Reject new exports | Let existing exports finish; queue new requests |
| Image upload | Accept and return quickly | Compress more aggressively or reduce resolution |
Priority-Based Rate Limiting
Not all requests deserve the same budget. Assign priority levels and enforce different limits.
| Priority | Example Requests | Limit Strategy |
|---|---|---|
| Critical | Login, payment, password reset | Highest limit, never degrade |
| Normal | Browse products, view profile | Standard limit |
| Background | Analytics sync, report generation | Lowest limit, can be queued |
| Bulk | Data export, batch processing | Explicit quota, not rate |
Common Failure Stories
Story 1: The Fixed Window Flash Sale
An e-commerce site used a fixed window rate limiter: 100 requests per minute. At 10:00:59, the flash sale started. Users hammered the API — 100 requests in the last second of the 10:00 window. At 10:01:00, a fresh window opened. Another 100 requests in the first second. The database received 200 requests in 2 seconds and fell over.
Root cause: Fixed window boundary burst. The rate limiter reported "under limit" for both windows while the database disagreed.
Fix: Switched to sliding window counter. The boundary burst disappeared because the effective rate was calculated continuously, not reset on hard boundaries.
Story 2: The Token Bucket DDoS
A streaming platform used a single token bucket with capacity 10,000 and 100 req/s refill for all API clients sharing a single API key. One misconfigured client sent requests at 200 req/s. The bucket drained from 10,000 to 0 in 50 seconds. Then every other client experienced 429 errors for 100 seconds while the bucket refilled.
Root cause: Shared bucket meant one noisy neighbor starved all others.
Fix: Per-client token buckets. Each client had its own bucket with capacity 1,000 and 10 req/s refill. The noisy neighbor only starved itself.
Story 3: Redis Goes Down, All Requests Pass
A financial API used Redis for distributed rate limiting with "fail open" mode — if Redis was unreachable, the rate limiter allowed the request. A Redis cluster node failed during peak trading hours. The rate limiter silently allowed all requests. The downstream trading engine received 50x normal traffic and crashed.
Root cause: Fail-open was the wrong policy for a financial system. Every rejected trade is better than a crashed system processing half-executed trades.
Fix: Changed to fail-closed for trading endpoints. The API returned 503 during the Redis outage. Trading stopped cleanly instead of crashing.
Fail-open vs fail-closed is a business decision, not a technical one. Ask: "What is worse — rejecting a legitimate request or allowing a request that crashes the system?" The answer changes by endpoint.
Story 4: The NAT Trap
A SaaS company rate-limited by IP address at 100 req/min. A university with 5,000 students behind a single NAT IP hit the limit in the first 30 seconds. Every student behind that NAT was blocked for the rest of the minute. Support tickets flooded in.
Root cause: Per-IP rate limiting breaks behind NAT, corporate proxies, and shared infrastructure.
Fix: Rate limit by authenticated user ID for logged-in traffic, and by IP only for anonymous traffic. Added a note: "IP-based limits are a coarse control for unauthenticated traffic."
Story 5: Clock Skew Chaos
A distributed rate limiter used each application server's local clock to compute time windows. Three app servers had clock skews of 2–5 seconds. A client hitting Server A (fast clock) would get rejected, then retry and hit Server B (slow clock), which still had capacity. The effective rate was double the configured limit.
Root cause: Distributed systems need a shared time source for rate limiting windows.
Fix: Used Redis TIME command (which returns the Redis server's clock) instead of time.time() from each app server.
Evaluation: How to Test Rate Limiters
Test Scenarios
| Test | What It Validates | How |
|---|---|---|
| Steady traffic | Sustained rate matches configuration | Send exactly limit requests per second for 60 seconds — all should pass |
| Burst traffic | Burst behavior is correct | Send limit * 2 requests in 1 second — exactly limit should pass, rest should be 429 |
| Window boundary | No boundary burst | Send at :59 and :00 — should not exceed limit across boundary |
| Distributed consistency | Shared state works | Send from two machines targeting different instances — combined rate should not exceed limit |
| Recovery | Counter resets correctly | Hit limit, wait full window, send again — should be allowed |
| Redis failure | Failure mode behavior | Kill Redis, send requests — should follow configured fail-open/fail-closed policy |
Metrics to Track
| Metric | What It Measures | Target |
|---|---|---|
| False positive rate | Legitimate requests incorrectly rejected | < 0.1% |
| False negative rate | Requests that should have been rejected but passed | < 0.01% |
| P99 latency overhead | How much the rate limiter adds to request time | < 2ms |
| Redis throughput | Operations per second on Redis cluster | < 80% of max capacity |
| Memory per client | For sliding log, how much memory each tracked client uses | < 1KB per active client |
Load Testing Approach
Send traffic at a controlled rate and measure how many requests are accepted versus rejected. Use a concurrency-limited client that sends exactly limit requests per second for a sustained duration, then compare the effective throughput against the configured limit. The test should verify that every request under the threshold passes, every request above it gets a 429, and recovery after the window reset works correctly. Run the same test from multiple machines to validate distributed consistency — the combined rate from all machines should not exceed the limit, even though each machine routes through different application instances.
End-to-End Protection Flow
Interview Prep
Rate limiting and throttling is a favorite interview topic — especially system design interviews for APIs, distributed systems, and infrastructure roles.
Common Interview Questions
-
Design the rate limiter for a public API. Start with requirements: 10K req/s, 1M clients, free and paid tiers. Walk through algorithm choice, distributed state (Redis), failure modes, and response headers.
-
How would you rate-limit a multi-tenant SaaS platform? Discuss per-tenant limits, shared vs isolated buckets, noisy neighbor protection, and how to enforce tenant isolation at the database level.
-
Your rate limiter is adding 10ms latency. How do you fix it? Local caching with short TTL, batching Redis calls, reducing Lua script complexity, moving rate limiting to the API gateway (L7 proxy).
-
What happens when Redis goes down? Fail-open vs fail-closed trade-off. Per-endpoint policies. Circuit breaker pattern for Redis calls. Local fallback counters as last resort.
-
Compare token bucket and leaky bucket. Token bucket allows bursts up to capacity, refills steadily. Leaky bucket enforces steady output, queues bursts. Token bucket is better for APIs, leaky bucket for downstream processing pipelines.
-
How do you rate-limit across data centers? Global Redis cluster with CRDT-based counters (e.g., Redis Enterprise with active-active geo-replication). Or per-region limits with a shared global budget that syncs asynchronously.
Key Takeaways
- Choose the algorithm by the behavior you need: fixed window is simple and cheap, token bucket supports bursts with long-term control, leaky bucket smooths traffic for fragile downstreams, sliding window gives the most precise window enforcement.
- Distributed limits need shared state and atomic operations: Redis + Lua scripts handle both. Never check-then-set without a lock or atomic operation.
- Limit by multiple dimensions: IP, user, tenant, route, and global. Order checks from cheapest to most expensive.
- Return useful headers:
429+Retry-After+ rate limit headers. Give clients enough information to self-regulate. - Fail gracefully: Decide per-endpoint whether to fail open or fail closed. Protect critical paths and shed non-essential work under load.
- Test your rate limiter: Boundary bursts, distributed consistency, Redis failure, and recovery scenarios.
Practice exercise: Design rate limiting for a public API with free and paid tiers. Include per-key limits, burst allowance (token bucket with capacity 3x the sustained rate), Redis-based distributed state, fail-open for GET endpoints and fail-closed for POST/PUT/DELETE, and client-facing X-RateLimit-* headers. Then simulate it at the Rate Limiters Simulator to see how different algorithms behave under real traffic patterns.