System design
Part 1 of 6 · Rate limitingRate Limiting: Token Bucket, Leaky Bucket & Sliding Window
Fixed-window 2× burst; sliding O(1) counter; token vs leaky bucket; Redis+Lua; 429/Retry-After.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Public API limiter: token bucket vs fixed window
Prefer
Token bucket (or sliding counter) with Redis + Lua
Burst is an explicit knob. The 2× seam is gone. Concurrent replicas cannot both undercount if check+debit is one script.
- Capacity = UX burst; refill r = sustained cost you can afford.
- O(1) memory per key; hash-tag the identity on Cluster.
- Sibling pages cover Lua atomicity, 429 headers, and tenant fairness.
Alternative
In-process fixed window on IP
One INCR per minute looks simple. At the boundary it admits 2×, N pods multiply the limit, and CGNAT punishes whole carriers.
- Last millisecond of window N plus first of N+1 both look empty.
- Local counters diverge under many pods — see distributed gateways.
- IP is not identity. Fairness belongs on user / API key / tenant.
Overview
Rate limiting is the controlled rejection (or delay) of excess traffic so a system stays available, fair, and affordable under load.
Abuse and security. Brute-force login, credential stuffing, scraping, and DoS amplification all look like "too many requests from an identity." Edge/WAF limits stop cheap attacks before origin. Per-route limits on /login, /password-reset, /otp are often tighter than global API quotas.
Fairness and multi-tenancy. One noisy tenant can starve everyone else. Product tiers map cleanly to limits (free = 10 RPS, pro = 100 RPS, enterprise = negotiated). Fairness keys (user_id, API key, tenant_id) matter more than IP alone (NAT, mobile carriers, corporate proxies).
Cost control. Every admitted request costs compute, egress, third-party API dollars, LLM tokens, or DB IOPS. Stripe's public guidance: treat limits as maximums, not targets. Client-side token buckets reduce 429 storms before they start.
Cascading failure prevention. When a dependency slows, retries amplify traffic → retry storms → total outage. Rate limits + circuit breakers + backoff with jitter are a layered defense. Leaky-bucket / outbound shaping protects fragile downstreams (SMS gateways, bank APIs, payment processors).
By the end you should be able to:
- Show why a fixed window admits up to 2× at the boundary.
- Contrast sliding log (exact,
O(n)memory) vs sliding counter (O(1), approximate). - Separate token bucket (burst + sustained) from leaky bucket (smooth egress; police vs shape).
- Place Redis + Lua for distributed atomicity, and design keys that survive Cluster hash slots.
- Return 429 +
Retry-After(and Limit / Remaining / Reset) — not 401 and not 503.
- 1
INCR + EXPIRE → 2× at the seam
Fixed window only
Cheap daily/hourly caps. Wrong as the only limiter on a public API — the boundary burst is the interview.
- 2
pick burst vs fairness → O(1) per key
Winner for interactive APIs: token bucket or sliding counter
Token bucket when legitimate spikes are a feature. Sliding counter when you want recent-history fairness without log memory. Drill: algorithms.
- ?
Atomic + layered + honest HTTP
Redis Lua for the global debit (atomic limiters). Local shed + regional store (gateways). Speak 429, not 503 (headers). Per-tenant quotas so one noisy neighbor cannot eat the fleet (fairness).
Algorithms compared
| Algorithm | Memory | Exact? | Burst behavior | Default when |
|---|---|---|---|---|
| Fixed window counter | O(1) — one integer + window id | Exact inside a window | Up to 2× across the boundary | Coarse daily/hourly caps, or layered under a finer limiter |
| Sliding window log | O(requests in window) | Exact over any rolling interval | No boundary exploit | Low-volume, high-value paths (admin, audit) |
| Sliding window counter | O(1) — two counters | Approximate | Smooths the 2× seam | Default for public APIs at scale |
| Token bucket | O(1) — tokens + timestamp | Exact vs the bucket | Burst = capacity; sustained = refill r | User-facing APIs that should tolerate legitimate spikes |
| Leaky bucket | O(1) (+ queue if shaping) | Exact vs leak rate | Smooth constant egress; queue or drop | Fragile downstreams; traffic shaping |
Fixed window counter
Partition time into aligned buckets (each minute, each hour). Count requests in the current bucket; reset when the clock crosses the boundary.
Pros: O(1) memory, trivial Redis INCR + EXPIRE, easy quotas ("1000/day").
Cons — the boundary burst: a client can send limit requests at 11:59:59 and another limit at 12:00:00 → up to 2× the intended rate in about two seconds. Downstream that cannot absorb 2× will melt.
Use for internal quotas, coarse caps, or layered with a finer limiter (per-second + per-minute).
Sliding window log
Store a timestamp (or unique id + score) for every request in the window. On each request: prune entries older than now - window, count remaining; admit if count < limit.
Pros: exact fairness; no boundary exploit.
Cons: memory O(requests in window). At 10k RPS per key this is expensive. Redis typically uses a sorted set (ZADD / ZREMRANGEBYSCORE / ZCARD) inside a Lua script for atomicity.
Sorted-set members must be unique (timestamp + sequence). Using only the timestamp as member silently drops same-ms requests.
Use for low-volume, high-value paths where exactness matters.
Sliding window counter (approximate / hybrid)
Keep two fixed-window counters — previous and current. Estimate:
estimated = prev_count * (1 - elapsed_in_current / window) + curr_countAdmit if estimated + 1 <= limit, then increment current.
Pros: O(1) memory, near-exact, smooths boundary bursts. Cloudflare published about a 0.003% wrong-decision rate across hundreds of millions of requests with this class of approach.
Cons: approximate; the formula assumes a roughly uniform distribution inside the previous window.
Use as the default for most public APIs at scale.
Token bucket
A bucket holds up to capacity tokens. Tokens refill at rate r (tokens/sec). Each request costs cost tokens (usually 1). If enough tokens → admit and subtract; else reject (or wait).
- Burst = capacity (empty → full instantaneously if idle long enough).
- Sustained rate = refill rate
r. - Idle clients accumulate burst; busy clients are capped at
r.
Pros: explicit burst vs sustained knobs; natural fit for developer APIs and SDKs. AWS API Gateway uses token bucket for throttling: rate = refill RPS, burst = bucket size.
Cons: a full-bucket burst can still overwhelm a fragile downstream even if average rate is fine.
Use for user-facing APIs that should tolerate legitimate spikes (page loads firing N parallel calls).
Leaky bucket
Requests enter a queue (bucket). The queue leaks (processes) at a constant rate. If the queue is full → drop (police) or block/wait (shape).
Pros: smooth, constant egress — ideal traffic shaping for SMS, payment batching, fragile partners.
Cons: adds latency under burst (queueing). Not ideal for interactive low-latency APIs if you shape rather than reject.
Deep dive · Throttling vs quota
Throttling = short-term rate (RPS / burst). Quota = longer budget (requests/day/month). Both often coexist — AWS usage plans are throttle + quota. A client can be well under the daily quota and still 429 on burst.
Where to enforce
| Layer | Pros | Cons |
|---|---|---|
| Client-side | Reduces 429s; good UX for SDKs | Untrusted; easily bypassed |
| Edge / CDN / WAF | Stops abuse early; cheap | Coarse keys (often IP); less app context |
| API Gateway | Central policy; auth-aware keys | Extra hop; config sprawl |
| Sidecar / service mesh | Per-service, language-agnostic | Ops complexity |
| Application | Full business context (tier, route cost) | Every service must implement correctly |
Production pattern: layer them. Edge blocks obvious abuse; gateway enforces global / API-key quotas; the service enforces expensive-route costs (search costs 10 tokens).
Local vs global. In-process is fast and needs no Redis; undercounts across replicas (N pods × limit = N× effective). Use for coarse protection. Global (Redis / DynamoDB) is shared truth across the fleet — you need atomic check-and-update (Lua / transactions).
Flow
- 1
Clients / SDKs
- nextEdge CDN / WAF: IP and path rules
- 2
Edge CDN / WAF: IP and path rules
- nextAPI Gateway: auth + usage plans
- 3
API Gateway: auth + usage plans
- atomic INCR / token refillRedis Cluster: Lua rate scripts
- 429 + Retry-AfterClients / SDKs
- 4
Redis Cluster: Lua rate scripts
- allowService / Sidecar
- 5
Service / Sidecar
- nextDB / Downstream APIs
- 6
DB / Downstream APIs
- 7
Clients / SDKs
Request path: Client → Edge (cheap IP/path rules) → Gateway (auth, extract key, EVAL Lua against Redis) → Service if allowed; else 429 with standard headers.
Diagrams - step by step
Three small diagrams for rate limiting. Step numbers in the labels give the animation order. The lesson map under Diagram 1 plays those steps.
Diagram 1 - Happy path: edge, gateway, Redis, service
Decisions
- 1
Step 1 Client request
- nextStep 2 Edge WAF applies cheap IP and path rules
- 2
Step 2 Edge WAF applies cheap IP and path rules
- nextStep 3 Gateway authenticates and builds the key rl:env:limit:identity:route
- 3
Step 3 Gateway authenticates and builds the key rl:env:limit:identity:route
- nextStep 4 EVALSHA Lua on Redis - refill and debit atomically
- 4
Step 4 EVALSHA Lua on Redis - refill and debit atomically
- nextStep 5 Allowed?
- Redis downFailure path - fail-open to a local limiter plus alert, money paths fail-closed
- ?
Step 5 Allowed?
- yesStep 6a Forward to the service
- noStep 6b 429 with Retry-After and RateLimit headers
- 6
Step 6a Forward to the service
- 7
Step 6b 429 with Retry-After and RateLimit headers
- 8
Failure path - fail-open to a local limiter plus alert, money paths fail-closed
The edge drops cheap abuse. The gateway builds the key and asks Redis to refill and debit in one script. Allowed traffic is forwarded. A deny is 429 with Retry-After. If Redis is down, fail open to a local limiter and alert, except on money paths, which fail closed.
Lesson map
Rate Limiting: Token Bucket, Leaky Bucket & Sliding Window
Diagram 1 walks 7 steps from Step 1 Client request through Step 6b 429 with Retry-After and RateLimit headers.
Architecture. Step 1 Client request Ready. Step 2 Edge WAF applies cheap IP and path rules Ready. Step 3 Gateway authenticates and builds the key rl:env:limit:identity:route Ready. Step 4 EVALSHA Lua on Redis - refill and debit atomically Ready. Step 5 Allowed? Ready. Step 6a Forward to the service Ready. Step 6b 429 with Retry-After and RateLimit headers Ready. Failure path - fail-open to a local limiter plus alert, money paths fail-closed Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB A["Step 1 Client request Ready"] B["Step 2 Edge WAF applies cheap IP and path rules Ready"] C["Step 3 Gateway authenticates and builds the key rl:env:limit:identity:route Ready"] D["Step 4 EVALSHA Lua on Redis - refill and debit atomically Ready"] E["Step 5 Allowed? Ready"] F["Step 6a Forward to the service Ready"] G["Step 6b 429 with Retry-After and RateLimit headers Ready"] X["Failure path - fail-open to a local limiter plus alert, money paths fail-closed Ready"] A -->|continues| B B -->|continues| C C -->|continues| D D -->|continues| E E -->|yes| F E -->|no| G D -->|Redis down| X
Diagram 2 - Failure path: fixed window boundary burst
Sequence
- 1
Client → Gateway fixed window 100 per min
Step 1 100 requests at 11:59:59
- 2
Gateway fixed window 100 per min → Service
Step 2 all admitted, window count is 100
- 3
Gateway fixed window 100 per min → Gateway fixed window 100 per min
Step 3 clock crosses 12:00 and the counter resets to 0
- 4
Client → Gateway fixed window 100 per min
Step 4 100 more requests at 12:00:00
- 5
Gateway fixed window 100 per min → Service
Step 5 all admitted - 200 requests in about 2 s
- 6
Client
Fix - sliding window counter or token bucket, or layer a per-second limit
A fixed window looks empty on both sides of the minute. The client sends the full limit at 11:59:59 and again at 12:00:00, so the service sees about 200 requests in two seconds. A sliding window, a token bucket, or a layered per-second limit removes that seam.
Diagram 3 - Decision: pick the algorithm
Decisions
- ?
Step 1 Allow bursts and cap sustained rate?
- yesToken bucket
- noStep 2 Smooth egress to a fragile downstream?
- 2
Token bucket
- Wrong pick for a fragile partner APIA full-bucket burst floods it
- ?
Step 2 Smooth egress to a fragile downstream?
- yesLeaky bucket
- noStep 3 Fairness over recent history?
- 4
Leaky bucket
- ?
Step 3 Fairness over recent history?
- noFixed window counter
- yesStep 4 Volume per key?
- 6
Fixed window counter
- Wrong pick for per-second protectionBoundary burst up to 2x
- ?
Step 4 Volume per key?
- low, exactness mattersSliding window log
- highSliding window counter
- 8
Sliding window log
- 9
Sliding window counter
- 10
Boundary burst up to 2x
- 11
A full-bucket burst floods it
Allow bursts and cap the sustained rate with a token bucket. Shape egress to a fragile partner with a leaky bucket. When fairness over recent history matters, use a sliding log at low volume and a sliding counter at high volume. A fixed window is only a coarse daily quota: as the only per-second gate it admits up to 2x at the boundary. A full token bucket still floods a partner that needed a smooth rate.
Redis + Lua for distributed atomicity
Naive GET → decide → INCR is a TOCTOU race: two concurrent requests both see "under limit" and both pass. Redis EVAL runs Lua atomically — no other command interleaves. Prefer Lua over MULTI/EXEC when you need branching on values just read. MULTI/EXEC queues commands but cannot conditionally skip based on intermediate reads without WATCH / retry loops. Full script walk, hash tags, EVALSHA, and fail-open vs fail-closed: Redis + Lua atomic rate limiters. Local vs global vs edge: distributed rate limits across gateways.
Key design
Compose keys from stable identity + scope:
rl:{env}:{limit_name}:{identity}:{route_or_resource}
rl:prod:api:user:42:/v1/search
rl:prod:login:ip:203.0.113.10
rl:prod:stripe_proxy:apikey:<hashed> # hash secrets; never log raw keysOn Redis Cluster, wrap the identity in hash-tag braces so prev/curr window keys share a slot:
rl:{user:42}:swcIdentity hierarchy: authenticated user > API key > device id > IP. IP alone fails behind CGNAT and shared egress.
HTTP headers and 429
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Max requests allowed in the window (or policy) |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Unix epoch (or seconds) when the window resets |
Retry-After | Seconds (or HTTP-date) the client should wait |
RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset | Emerging IETF names — document both if you control the API |
429 Too Many Requests means "slow down" — not auth failure (401/403) and not server error (5xx). Returning 503 trains clients to treat this as capacity failure and fail over, which is the wrong behavior. Header names, Retry-After vs Reset, and client jitter: HTTP 429, RateLimit headers & Retry-After. Per-tenant quotas vs raw RPS: fairness, quotas & noisy neighbors.
Body should be machine-readable, for example:
{ "error": "rate_limited", "retry_after_seconds": 12 }Clients: exponential backoff with jitter; honor Retry-After when present.
Idempotency interaction. Rate limiters often run before idempotency caches. A 429 may not reserve the idempotency key the same way a processed request does — clients must understand retry semantics for their platform.
Worked examples
Sandboxes below are in-memory. Redis stays in teaching fences (no network in the playground).
Press Run. Snippets must be self-contained — no network, files, or native modules.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Redis sliding window counter (teaching fence — needs Redis)
Hash-tag the base key so both window keys share a Cluster slot. Run as one EVAL so GET/INCR cannot race across pods.
"""
estimated = prev * (1 - t/W) + curr
Admit if estimated < limit, then INCR current window key.
"""
SLIDING_COUNTER_LUA = """
-- KEYS[1] = base key with hash tag, e.g. rl:{user:42}
-- ARGV[1] = window_ms, ARGV[2] = limit, ARGV[3] = now_ms
local base = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local curr_id = math.floor(now / window)
local prev_id = curr_id - 1
local curr_key = base .. ':' .. curr_id
local prev_key = base .. ':' .. prev_id
local curr = tonumber(redis.call('GET', curr_key) or '0')
local prev = tonumber(redis.call('GET', prev_key) or '0')
local elapsed = now % window
local weight = 1.0 - (elapsed / window)
local estimated = prev * weight + curr
if estimated >= limit then
local retry = math.ceil((estimated - limit + 1) / math.max(prev, 1) * window)
return {0, 0, retry}
end
curr = redis.call('INCR', curr_key)
redis.call('PEXPIRE', curr_key, window * 2)
local remaining = math.max(0, math.floor(limit - (prev * weight + curr)))
return {1, remaining, 0}
"""
def allow_sliding_counter(redis, user_id: str, limit: int, window_ms: int = 60_000) -> dict:
import time
base = f"rl:{{{user_id}}}:swc"
now_ms = int(time.time() * 1000)
allowed, remaining, retry = redis.eval(
SLIDING_COUNTER_LUA, 1, base, window_ms, limit, now_ms
)
return {
"allowed": bool(allowed),
"remaining": int(remaining),
"retry_after_ms": int(retry),
}import Redis from "ioredis";
const SLIDING_COUNTER_LUA = `
local base = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local curr_id = math.floor(now / window)
local prev_id = curr_id - 1
local curr_key = base .. ':' .. curr_id
local prev_key = base .. ':' .. prev_id
local curr = tonumber(redis.call('GET', curr_key) or '0')
local prev = tonumber(redis.call('GET', prev_key) or '0')
local elapsed = now % window
local weight = 1.0 - (elapsed / window)
local estimated = prev * weight + curr
if estimated >= limit then
local retry = math.ceil((estimated - limit + 1) / math.max(prev, 1) * window)
return {0, 0, retry}
end
curr = redis.call('INCR', curr_key)
redis.call('PEXPIRE', curr_key, window * 2)
local remaining = math.max(0, math.floor(limit - (prev * weight + curr)))
return {1, remaining, 0}
`;
export async function allowSlidingCounter(
redis: Redis,
userId: string,
limit: number,
windowMs = 60_000,
): Promise<{ allowed: boolean; remaining: number; retryAfterMs: number }> {
const base = `rl:{${userId}}:swc`;
const nowMs = Date.now();
const [allowed, remaining, retry] = (await redis.eval(
SLIDING_COUNTER_LUA,
1,
base,
windowMs,
limit,
nowMs,
)) as [number, number, number];
return { allowed: allowed === 1, remaining, retryAfterMs: retry };
}Redis token bucket Lua (sketch)
-- KEYS[1]=bucket hash; ARGV: capacity, refill_per_ms, now_ms, cost
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or capacity)
local last = tonumber(redis.call('HGET', KEYS[1], 'ts') or now)
tokens = math.min(capacity, tokens + (now - last) * refill)
if tokens < cost then
return {0, tokens, math.ceil((cost - tokens) / refill)}
end
tokens = tokens - cost
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 3600000)
return {1, tokens, 0}Distributed pitfalls
Clock skew. App-supplied timestamps across pods disagree → under/over counting. Prefer Redis TIME inside Lua for sorted-set sliding logs, or accept that window IDs from app clocks are approximate. NTP drift of tens of ms is usually fine for second-scale windows; sub-second windows are harder.
TOCTOU races. Separate read/decide/write commands allow concurrent admits past the limit. Fix: single Lua EVAL (or Redis transactions with careful WATCH — Lua is simpler).
Hot keys. One celebrity user / viral endpoint → all traffic hits one Redis key → CPU hot spot. Mitigations: local token bucket in front of Redis (absorb micro-bursts), key sharding with probabilistic admission, Redis Cluster + careful hash tags, tiered limits (coarse local + fine global).
Multi-region. Global Redis cross-region adds latency and split-brain risk. Common pattern: regional limits (strong) + async global quotas (eventual), or sticky users to a region. Edge rate limits (Cloudflare colo-local) are intentionally local unless you design otherwise — document the semantics.
Fail-open vs fail-closed. If Redis is down: fail-open preserves availability (risk: overload); fail-closed preserves safety (risk: total outage). Senior answer: fail-open with local fallback limits + alert; critical money paths may fail-closed.
Costed requests. Not all requests cost 1: search = 10, upload = 50. Token bucket with variable cost models this; fixed window counters need weighted increments. GraphQL: parse query complexity/depth, charge N tokens per field — simple request-count limits are trivial to bypass with nested queries.
Interview Q&A
Fixed window vs sliding window — what breaks in production?
Answer
Fixed window allows up to 2× at boundaries. Sliding log is exact but O(n) memory. Sliding counter approximates with O(1) memory and is the usual production default.
When do you pick token bucket over leaky bucket?
Answer
Token bucket when controlled bursts are a feature (UX, parallel SDK calls). Leaky bucket when downstream needs smooth constant egress (SMS, bank APIs) and you can accept queue latency or drops. Distinguish policing (drop) vs shaping (delay).
How does AWS API Gateway throttle?
Answer
Token bucket: rate = sustained RPS (refill), burst = bucket capacity. Limits are best-effort targets; excess → 429. Applied at account, stage, method, and usage-plan/client layers.
How do you implement distributed rate limiting correctly?
Answer
Centralize state in Redis (or equivalent), run check+update in one Lua script, design keys with Cluster hash tags, return Remaining / Retry-After, and layer local + global limits for hot keys.
What headers do you return on success and on 429?
Answer
Always useful: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. On deny: HTTP 429 + Retry-After. Optionally IETF RateLimit-* headers. Never use 503 to mean "slow down."
IP-based limiting failed for mobile users — why?
Answer
Carrier-grade NAT shares IPs across thousands of users; corporate egress does too. Prefer authenticated identity / API key; use IP as a secondary signal for anonymous traffic.
How would you rate-limit a GraphQL API?
Answer
Cost-based: parse query complexity/depth, charge N tokens per field, enforce a token bucket per user. Simple request-count limits are trivial to bypass with nested queries.
Redis Lua vs MULTI/EXEC?
Answer
Lua can branch on values just read and always completes atomically in one RTT. MULTI/EXEC queues commands but cannot conditionally skip based on intermediate reads without WATCH / retry loops.
How do Stripe-style platforms avoid retry storms?
Answer
Publish clear limits, return structured 429 reasons, recommend client token buckets, exponential backoff with jitter, and separate concurrency limits from RPS limits.
Design rate limits for a multi-tenant SaaS.
Answer
Per-tenant quotas by plan; per-route multipliers; soft limit (warn) vs hard limit (429); burst via token bucket; regional Redis; dashboards for remaining quota; override / boost for incidents.
Sliding window counter formula — walk through numbers.
Answer
Window W = 60s, limit = 100. At t = 30s into the current window, prev = 80, curr = 40 → estimated = 80 × 0.5 + 40 = 80 → allow. Near the boundary with prev = 100, curr = 0 early → still blocks residual previous traffic.
What is the difference between throttling and quota?
Answer
Throttling = short-term rate (RPS/burst). Quota = longer budget (requests/day/month). Both often coexist (AWS usage plans: throttle + quota).
Pitfalls
| Pitfall / choice | Risk if ignored | Mitigation |
|---|---|---|
| Fixed window only | 2× boundary burst | Sliding counter or token bucket |
| In-process only | N× limit across pods | Redis global + optional local shed |
| Non-atomic Redis | Limit bypass under concurrency | Lua EVAL |
| IP-only keys | False positives on NAT | User / API-key primary |
| Fail-closed on Redis outage | Total API outage | Local fallback + alert; critical paths TBD |
No Retry-After | Client stampede | Always send Retry-After + jitter guidance |
| One global limit | Hot key / unfair routes | Hierarchical: global + per-route + cost |
| Exact sliding log at high RPS | Redis memory blowup | Sliding counter or token bucket |
| Ignoring clock skew | Wrong window id | Redis TIME / coarser windows |
| Same limit for all methods | Cheap GETs starve POST /search | Weighted costs / per-route policies |
| Returning 503 instead of 429 | Wrong client behavior | 429 for rate; 503 for capacity |
| No observability | Blind during incidents | Metrics: allow/deny by key class, Redis latency |
Design limits for GET /v1/search on a multi-tenant SaaS. Write: (1) the Redis key with a Cluster hash tag, (2) token-bucket rate and burst, (3) a weighted cost vs a cheap GET /healthz, (4) what you return on deny, (5) fail-open vs fail-closed if Redis is down. Then show the 2× burst a fixed window would allow at the minute boundary and why you did not use one.
Cheat sheet
| Topic | Memorize |
|---|---|
| Fixed window | Simple, 2× boundary bug |
| Sliding log | Exact, expensive memory |
| Sliding counter | O(1), near-exact — default at scale |
| Token bucket | Burst + sustained (AWS API Gateway, Stripe-style) |
| Leaky bucket | Smooth egress / shaping (police vs shape) |
| Distributed | Redis + Lua; hash tags; fail-open with local fallback |
| HTTP | 429 + Limit / Remaining / Reset + Retry-After |
| Keys | user / API key > IP; costed routes for GraphQL / search |
Go Deeper
- AWS API Gateway throttling (token bucket rate + burst)
- Cloudflare WAF rate limiting rules
- Stripe rate limits (429, concurrency, Retry guidance)
- Stripe Engineering — Scaling your API with rate limiters
- Redis — Build 5 rate limiters with Lua
- Kong Gateway rate limiting plugin
- NGINX — limiting access to proxied HTTP resources (
limit_req) - Arcjet — algorithms compared (fixed, sliding, token, leaky)
Cluster: algorithms · Redis + Lua · gateways · 429 headers · fairness