06 - Design a Rate Limiter
📋 Jump to Takeaways🎁 A single bad actor can send 10,000 requests per second to your API. How do you stop them without slowing down everyone else?
A rate limiter controls how many requests a user (or IP, or API key) can make in a given time window. It protects your services from abuse, prevents DDoS attacks, and ensures fair usage. This lesson introduces the core algorithms, where to place the limiter, and why Redis is the standard implementation choice.
Rate Limiter Requirements
Rate Limiter Functional Requirements
- Limit requests per user/IP/API key within a time window (e.g., 100 requests per minute)
- Return HTTP 429 (Too Many Requests) when limit is exceeded
- Include rate limit headers in every response so clients can self-throttle
- Support different limits per endpoint (e.g., /api/search: 10/min, /api/posts: 100/min)
- Distributed: works correctly across multiple API servers
Rate Limiter Non-Functional Requirements
| Metric | Target |
|---|---|
| Latency overhead | < 5ms added per request (sits in the hot path) |
| Accuracy | No more than 1% over-allowance at window boundaries (rules out fixed window, not token bucket) |
| Availability | If the limiter fails, traffic should still flow (fail open) |
| Scale | Handle 100K requests/sec across the fleet |
| Consistency | Same user hitting different servers gets the same limit |
Rate Limiter Scope Exclusions
- DDoS mitigation at the network layer (that's Cloudflare/AWS Shield)
- Billing/metering (counting for invoicing, not blocking)
- Adaptive rate limiting (adjusting limits based on server load)
Rate Limiter Estimation
Rate Limiter Traffic
- 100K requests/sec across all servers
- Each request requires one rate limit check (read + increment)
- That's 100K Redis operations/sec, well within a single Redis instance's capacity
Rate Limiter Storage
- Per user: one counter + TTL = ~50 bytes
- 10M active users = 500 MB in Redis
- Tiny. Fits easily in memory.
Rate Limiter Key Insight
The rate limiter sits in the request hot path (the code executed on every single request). Every single API request passes through it. If it adds 50ms of latency, your entire API is 50ms slower. This is why it must be in-memory (Redis) and why the algorithm must be simple.
Rate Limiter High-Level Design
┌────────────────────────────────────────────────────────────────┐
│ RATE LIMITER │
├────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────────────────┐ ┌────────┐ │
│ │ Client │────►│ API Server │────►│ Handler│ │
│ │ │◄────│ │◄────│ (logic)│ │
│ └──────────┘ │ ┌────────────────────┐ │ └────────┘ │
│ │ │ Rate Limit Check │ │ │
│ │ │ (middleware) │ │ │
│ │ └─────────┬──────────┘ │ │
│ └────────────┼─────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ Redis │ │
│ │ (shared counters) │ │
│ │ one key per user │ │
│ │ per time window │ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────┘Flow: Every request hits the rate limit middleware first. It checks Redis (is this user over the limit?). If yes → 429. If no → increment counter, pass to handler.
Rate Limiter Placement
Option A: In each API server (middleware)
┌──────────┐ ┌─────────────────────────────┐
│ Client │────►│ API Server │
└──────────┘ │ ┌───────────┐ ┌────────┐ │
│ │Rate Limit │─►│ Handler│ │
│ │middleware │ └────────┘ │
│ └─────┬─────┘ │
└────────┼────────────────────┘
▼
┌──────────────┐
│ Redis │
│ (shared │
│ counters) │
└──────────────┘
Option B: As a separate service (API Gateway)
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │────►│ API Gateway │────►│ API Server │
└──────────┘ │ (rate limit │ └──────────────┘
│ + routing) │
└──────┬───────┘
▼
┌──────────────┐
│ Redis │
└──────────────┘| Approach | Pros | Cons |
|---|---|---|
| Middleware in each server | Simple, no extra hop | Every server needs Redis connection |
| Separate API Gateway | Centralized, language-agnostic | Extra network hop, single point of failure |
Both are valid. Middleware is simpler for small teams. API Gateway (Kong, AWS API Gateway) is better when you have many services in different languages.
Why Redis is shared: If user "alice" hits Server A and Server B, both must see the same counter. A local in-memory counter per server would allow 100 × N requests (where N = number of servers).
Rate Limiter Deep Dive
Algorithm 1: Fixed Window Counter
The simplest approach. Count requests in fixed time windows:
Window: 10:00:00 - 10:00:59 → counter = 0
Request at 10:00:05 → counter = 1 ✓
Request at 10:00:30 → counter = 2 ✓
...
Request at 10:00:55 → counter = 100 ✓
Request at 10:00:58 → counter = 101 ✗ REJECTED
Window: 10:01:00 - 10:01:59 → counter resets to 0Redis implementation:
key: ratelimit:{user_id}:{minute}
e.g., ratelimit:alice:202605271000
# Derive the minute key from current timestamp:
# minute_key = floor(unix_timestamp / 60)
# In code: Math.floor(Date.now() / 60000) or time.Now().Unix() / 60
INCR ratelimit:alice:202605271000
EXPIRE ratelimit:alice:202605271000 60The boundary problem: A user sends 100 requests at 10:00:59 and 100 more at 10:01:00. That's 200 requests in 2 seconds, double the intended limit. The window boundary creates a loophole.
Algorithm 2: Sliding Window Counter
Fixes the boundary problem by blending the current and previous window:
Previous window (10:00): 80 requests
Current window (10:01): 30 requests
Current position: 15 seconds into the minute (25% through)
Estimated count = prev × overlap% + current
= 80 × 0.75 + 30
= 60 + 30 = 90
Limit is 100 → 90 < 100 → ALLOWWhy this works: Instead of a hard reset at the boundary, it smoothly transitions between windows. The estimate is ~99.7% accurate, good enough for rate limiting.
Redis implementation: Store two counters (current window + previous window) and compute the weighted sum on each request.
Algorithm 3: Token Bucket
A different mental model. Imagine a bucket that fills with tokens at a steady rate:
Bucket capacity: 10 tokens
Refill rate: 1 token per second
Request arrives:
Tokens available? → Take one, allow request
Bucket empty? → Reject (429)
After 10 seconds idle: bucket is full (10 tokens)
Burst of 10 requests: all allowed (empties bucket)
11th request immediately after: rejected (no tokens)
Wait 1 second: 1 token available againWhy token bucket is popular:
- Allows short bursts (up to bucket capacity) while enforcing average rate
- Simple to understand and implement
- Used by AWS, Stripe, and most API providers
Redis implementation:
Store: { tokens: 7, last_refill: 1716800000 }
On each request:
1. Calculate tokens to add since last_refill
2. Add tokens (cap at bucket capacity)
3. If tokens > 0: decrement, allow
4. Else: rejectComparing the Algorithms
| Algorithm | Burst handling | Accuracy | Complexity | Best for |
|---|---|---|---|---|
| Fixed window | Allows 2× at boundaries | Low | Simplest | Internal services, non-critical |
| Sliding window | Smooth, no boundary spike | High (~99.7%) | Medium | User-facing APIs |
| Token bucket | Allows controlled bursts | High | Medium | APIs where bursts are acceptable |
Interview default: Sliding window counter. It's the most commonly asked and balances simplicity with accuracy.
Atomic Operations with Lua
The rate limit check must be atomic. "Read counter, check limit, increment" must happen as one operation. Otherwise:
Thread A: reads counter = 99
Thread B: reads counter = 99
Thread A: 99 < 100, increments to 100 ✓
Thread B: 99 < 100, increments to 101 ✗ (over limit!)Solution: Redis Lua script. It executes atomically on the server:
-- KEYS[1] = rate limit key (e.g., ratelimit:alice:202605271000)
-- ARGV[1] = window TTL in seconds (e.g., 60)
-- ARGV[2] = max allowed requests (e.g., 100)
local count = redis.call('INCR', KEYS[1]) -- increment counter
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1]) -- set TTL on first request only
end
if count > tonumber(ARGV[2]) then
return 0 -- rejected: over limit
end
return 1 -- allowed: under limitOne network round trip, fully atomic. No race conditions.
Response Headers
Every response should include rate limit info so clients can self-throttle:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1716800060 (Unix timestamp when window resets)
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1716800060
Retry-After: 30Good clients read these headers and back off before hitting 429. Bad clients ignore them. That's what the limiter is for.
Rate Limiting in the Request Lifecycle
An important subtlety: application-level rate limiting happens after the full request is received. If a user POSTs a 50 MB file and gets 429'd, your server already received all 50 MB. You just didn't process it.
To reject large requests early (before receiving the body), you need rate limiting at the load balancer or gateway level. It can reject based on headers alone without reading the body. This is another reason API Gateways are popular for rate limiting.
Redis Failure Handling
The rate limiter is in the hot path. If Redis is unreachable:
| Strategy | Behavior | When to use |
|---|---|---|
| Fail open | Allow all traffic (no limiting) | User-facing APIs, downtime is worse than temporary abuse |
| Fail closed | Reject all traffic | Payment/security endpoints, abuse is worse than downtime |
Default: Fail open. A few seconds without rate limiting is better than rejecting every request. Alert the team and fix Redis.
Rate Limiting Dimensions
You can limit by multiple dimensions simultaneously:
Per user: 100 requests/min (authenticated users)
Per IP: 50 requests/min (anonymous/unauthenticated)
Per endpoint: /api/search: 10/min, /api/upload: 5/min
Per method: POST: 20/min, GET: 200/minEach dimension is a separate Redis key:
ratelimit:user:alice:minute:202605271000
ratelimit:ip:203.0.113.1:minute:202605271000
ratelimit:endpoint:/api/search:user:alice:minute:202605271000Key Takeaways
The rate limiter sits in the hot path, and latency is critical
Every request passes through it. Redis gives sub-millisecond checks. A slow rate limiter makes your entire API slow. Keep the algorithm simple and the data store fast.
Sliding window counter is the interview default
It fixes the boundary burst problem of fixed windows without the complexity of token buckets. Weighted average of current + previous window gives ~99.7% accuracy.
Lua scripts make Redis operations atomic
Read-check-increment must be one atomic operation to prevent race conditions. A Lua script executes on the Redis server in a single step. No interleaving from other clients.
Fail open when Redis is down
For user-facing APIs, rejecting all traffic is worse than allowing temporary abuse. Fail open, alert the team, fix Redis. For payment endpoints, fail closed instead.
Shared state (Redis) is required for distributed rate limiting
Local counters per server allow N× the limit (where N = server count). All servers must check the same counter in Redis to enforce a global limit per user.
Response headers enable cooperative clients
X-RateLimit-Remaining and Retry-After let well-behaved clients self-throttle before hitting 429. The limiter still protects against bad clients, but good ones never trigger it.
🎁 What if you need just two operations, GET and PUT, but they need to work across hundreds of nodes with no single point of failure?