Scalability & Performance

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.

rate limitingtoken bucketthrottlingsliding windowRedis

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.

CONCEPTWHAT IT DOESTYPICAL RESPONSEEXAMPLE
Rate LimitingRejects requests above a threshold within a time window429 Too Many RequestsMax 100 API calls per minute per API key
ThrottlingControls request processing rate by delaying, slowing, or limiting throughputIncreased latency, queueing, reduced throughput, or request rejectionVideo streaming bitrate reduction on congested networks
QuotaAllows a fixed amount of usage over a longer period (daily/monthly)429 Quota Exceeded or Usage Limit Reached10,000 LLM tokens per day on a free tier
BackpressureSignals upstream producers to slow down when consumers cannot keep upFlow-control signals, reduced send rateReactive 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?"

ApproachProblem
Trust clientsBuggy loops, retry storms, misconfigured scripts, and malicious actors exist
Rely on the load balancerLBs distribute traffic evenly but don't track per-client state
Use a single counterA global counter allows one aggressive client to exhaust the budget for everyone
Just reject when CPU is highBy 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).

ProsCons
Simple to implement — one Redis key per windowBoundary bursts: 100 req at :59 + 100 req at :00 = 200 in 2 seconds
Low memory — only a counter and a TTLUnfair near window boundaries
Easy to reason aboutLess 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.

ProsCons
Perfectly accurate — no boundary burstStores every timestamp — O(n) memory per client
Smooth at any granularityHigher CPU for cleanup on every request
Works for any window lengthExpensive 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.

text
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.

ProsCons
Smooth — no boundary burstsApproximate — can be slightly over or under the true count
Cheap — only two counters per clientMore complex to implement and reason about
Good enough for most API gatewaysApproximation 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.

ParameterMeaningExample
Refill rateSustained long-term rate10 tokens/second = 36,000 requests/hour
Bucket sizeMaximum allowed burst100 tokens → client can send 100 requests instantly
Cost per requestTokens consumed1 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.

ProsCons
Smooth, predictable traffic patternAdds latency — requests wait in queue
Protects fragile downstream systemsQueue can mask growing overload
Easy to model and capacity-planQueue 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 trafficFixed windowOne counter, one TTL. Good enough for admin panels
Smooth rate, moderate trafficToken bucketBursts allowed, steady long-term. Best all-rounder
Precise per-request windowSliding window logEvery timestamp counted. Expensive but exact
Smooth + cheap, high trafficSliding window counterTwo counters per key. Approximate but good
Downstream protectionLeaky bucketForces 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

ConcernTrade-offRecommendation
AtomicityRace on check-then-incrementAlways use Lua or INCR with expiry in one call
LatencyEvery request hits RedisKeep Redis in the same region; consider local cache fallback
Hot keysOne key per user is fine; one key for a popular tenant creates a hotspotPartition by user, not by tenant ID, for hot tenants
Failure modeRedis goes down — allow all or block all?Fail open for read APIs, fail closed for write APIs
Clock skewApp servers disagree on timeUse 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:

DimensionWhat It ProtectsExample
Per IPAnonymous scraping100 req/min per IP for the search API
Per userAuthenticated usage fairness1000 req/min for free tier, 10,000 for pro
Per tenantMulti-tenant isolation5000 req/min per enterprise tenant
Per routeExpensive endpoints10 req/min for export, 1000 for list
Per API keyDeveloper platform tiers1000 req/hour for dev keys, 100,000 for prod
GlobalEntire service1M req/min across all clients — emergency brake
Per downstreamDatabase, third-party APIs500 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

CodeWhen to UseMeaning
429 Too Many RequestsClient exceeded their rate limitBack off and retry later
503 Service UnavailableServer is overloaded globallyThe entire service is under pressure

Standard Headers

http
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
}
HeaderPurpose
Retry-AfterSeconds until the client can retry (machine-readable)
X-RateLimit-LimitThe maximum requests allowed in the window
X-RateLimit-RemainingHow many requests the client has left
X-RateLimit-ResetUnix 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 SituationCritical PathDegrade
Search API under loadAllow search for logged-in usersServe cached or partial results for anonymous users
Analytics dashboardKeep real-time metricsUse 5-minute-old stale data
Recommendation serviceServe somethingFall back to popular items instead of personalized
Notification fanoutCritical alerts (password reset)Queue promotional emails for later delivery
Bulk export endpointReject new exportsLet existing exports finish; queue new requests
Image uploadAccept and return quicklyCompress more aggressively or reduce resolution

Priority-Based Rate Limiting

Not all requests deserve the same budget. Assign priority levels and enforce different limits.

PriorityExample RequestsLimit Strategy
CriticalLogin, payment, password resetHighest limit, never degrade
NormalBrowse products, view profileStandard limit
BackgroundAnalytics sync, report generationLowest limit, can be queued
BulkData export, batch processingExplicit 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

TestWhat It ValidatesHow
Steady trafficSustained rate matches configurationSend exactly limit requests per second for 60 seconds — all should pass
Burst trafficBurst behavior is correctSend limit * 2 requests in 1 second — exactly limit should pass, rest should be 429
Window boundaryNo boundary burstSend at :59 and :00 — should not exceed limit across boundary
Distributed consistencyShared state worksSend from two machines targeting different instances — combined rate should not exceed limit
RecoveryCounter resets correctlyHit limit, wait full window, send again — should be allowed
Redis failureFailure mode behaviorKill Redis, send requests — should follow configured fail-open/fail-closed policy

Metrics to Track

MetricWhat It MeasuresTarget
False positive rateLegitimate requests incorrectly rejected< 0.1%
False negative rateRequests that should have been rejected but passed< 0.01%
P99 latency overheadHow much the rate limiter adds to request time< 2ms
Redis throughputOperations per second on Redis cluster< 80% of max capacity
Memory per clientFor 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

  1. 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.

  2. 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.

  3. 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).

  4. 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.

  5. 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.

  6. 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

  1. 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.
  2. Distributed limits need shared state and atomic operations: Redis + Lua scripts handle both. Never check-then-set without a lock or atomic operation.
  3. Limit by multiple dimensions: IP, user, tenant, route, and global. Order checks from cheapest to most expensive.
  4. Return useful headers: 429 + Retry-After + rate limit headers. Give clients enough information to self-regulate.
  5. Fail gracefully: Decide per-endpoint whether to fail open or fail closed. Protect critical paths and shed non-essential work under load.
  6. 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.