Loading…
Loading…
Design a rate limiter that restricts the number of requests a client can make to an API within a time window. Support multiple limiting strategies and work correctly in a distributed environment.
For 10M users, each making up to 100 req/min:
API Request
│
▼
[Rate Limiter Middleware]
│ checks Redis
├── Allowed → forward to API handler
└── Denied → 429 Too Many Requests
[Redis Cluster]
key: ratelimit:{userId}:{window}
value: request count
TTL: window size
A naive GET count then INCR from the application has a race window between two app servers checking simultaneously — both could read "under limit" before either increments. Two fixes:
INCR ratelimit:{userId}:{window}
EXPIRE ratelimit:{userId}:{window} <window_seconds> -- only on first increment
INCR itself is atomic in Redis, but the INCR + conditional EXPIRE pair is not — the standard fix is a Lua script, which Redis executes atomically as a single operation:
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return current
Running this via EVAL guarantees the increment-and-maybe-expire happens as one atomic unit, closing the race window entirely.
The rate limiter is middleware, not an API itself. Response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1735689600
Retry-After: 37 (only on 429)
Fixed window counter:
SET ratelimit:user123:1735689600 0 EX 60
INCR ratelimit:user123:1735689600
Sliding window log (more accurate, more memory):
ZADD ratelimit:user123 <timestamp> <requestId>
ZREMRANGEBYSCORE ratelimit:user123 0 <now - window>
ZCARD ratelimit:user123
Use a Redis Cluster to distribute the keyspace. Each rate limiter instance talks to the same Redis cluster, so limits are enforced globally across all API servers.
Production systems commonly layer these: a coarse gateway-level limit as the primary defense, with finer per-service limits for specific expensive endpoints.
A production API gateway rarely enforces one global limit — different tenants (or pricing tiers) need different limits, and those limits need to be changeable without a redeploy. This means the rate limit rule itself (not just the counter) needs to be looked up dynamically per request: GET ratelimit-rule:{tenantId} from a fast config store (Redis, or an in-memory cache refreshed periodically from a config service) rather than hardcoded in application code. A common addition here is a quota (a longer-window cap — e.g. 1M requests/month) layered on top of the short-window rate limit (100 req/min) — the two serve different purposes: rate limiting protects against short bursts, quota enforcement protects the billing/business model, and a gateway often needs to check both independently.
Since a gateway-level limiter sits in front of every request to every backend service, its own reliability matters more than a per-service limiter's would — a gateway rate limiter that adds significant latency or becomes unavailable affects the entire system, not just one endpoint. This is why the fail-open-vs-fail-closed decision (see Failure Scenarios below) is especially consequential at the gateway layer, and why the Redis dependency backing it needs its own health checks (Health Checks) feeding into the gateway's own resilience posture (a circuit breaker around the Redis call itself, defaulting to fail-open on timeout, is a common production pattern).
Redis is the single source of truth. Use Redis Cluster for horizontal scaling and Redis Sentinel for failover.
If Redis is unavailable: fail open (allow all requests) or fail closed (deny all). Most APIs choose fail open to avoid an outage becoming a complete blackout.
Q: Token bucket or sliding window: which should I choose? A: A token bucket allows controlled bursts (up to the bucket size) while enforcing an average rate, which suits most APIs and is cheap to store (a token count and a timestamp). Sliding-window counters give smoother, more precise limits over an exact time window, avoiding the burst at fixed-window boundaries. Many systems use a token bucket for per-client limits, and a sliding window where fairness at boundaries matters.
Q: How do you enforce a limit consistently across many API servers? A: Keep the counters in a shared, fast store such as Redis, and update them atomically, with a Lua script or atomic increments with expiry, so concurrent requests on different servers can't both slip through. To cut latency, servers can also keep small local token allowances that they refill from the central store, trading a little precision for fewer round trips.
Q: What should happen if the rate-limit store is down? A: Decide explicitly between failing open (allow requests, protecting availability but temporarily losing protection) and failing closed (reject requests, protecting backends but causing an outage). Most public APIs fail open with local, per-server fallback limits, and alert. Sensitive endpoints such as login can fail closed.
Q: How should the API tell clients they're being limited?
A: Return HTTP 429 Too Many Requests, with a Retry-After header, and ideally headers showing the limit, the remaining quota and the reset time. That lets well-behaved clients back off precisely, instead of hammering the endpoint with retries.
Q: At which layer should rate limiting happen? A: Usually in several. Edge or CDN limits absorb abusive floods cheaply. The API gateway enforces per-client and per-key quotas. Individual services protect their own expensive operations and dependencies. Each layer handles a different threat, and a single layer is rarely enough.
Q: How do you rate-limit fairly by user rather than by IP? A: Key the limit by authenticated identity (API key, user ID or tenant) whenever possible, since many users can share one IP (offices, mobile carriers) and one attacker can use many IPs. Apply IP-based limits only to unauthenticated endpoints, with generous thresholds and additional signals.