← Problems

Distributed Rate Limiter

mediummid / senior
Start timed practice

Limit requests per client across a fleet of API servers with low latency and predictable behaviour under failure.

Requirements

Functional

  • Limit the number of requests a client can make in a time window (e.g. 100 requests/minute per API key).
  • Rules are configurable per endpoint, per client tier and per dimension (user id, API key, IP).
  • Rejected requests get a 429 with headers that tell the client when to retry.
  • Limits apply across the whole fleet, not per server: 100/min means 100/min no matter which server handles the request.
  • Operators can change limits without redeploying.

Non-functional

  • Added latency on the request path must be tiny: p99 < 2 ms for the limit check.
  • High availability: the limiter must never take the API down. Decide explicitly what happens when the limiter's state store is unreachable.
  • Accuracy: a few percent over-admission under contention is acceptable; a 2× burst at window edges is not.
  • Memory per tracked client must be small, since there can be tens of millions of keys.

Out of scope

  • DDoS mitigation at the network layer (that lives in the CDN/WAF).
  • Billing and quota accounting over months (a different system with exact counts).

Back-of-envelope estimates

Assumptions

  • 10M daily active API clients, 1M concurrently active in any given minute.
  • Fleet handles 100K requests/s at peak; every request does one limit check.
  • One rule per request on average, two counters per rule for sliding window.

Throughput on the state store

  • 100K checks/s × 1 atomic operation each = 100K ops/s. A single Redis node does ~100K–200K simple ops/s, so plan 2–4 shards for headroom, more if you run Lua scripts that touch several keys.
  • With client-side batching (pre-aggregation, see deep dives) this can drop 10× to ~10K ops/s.

Memory

  • Per key: key string (~40 B) + counter (8 B) + window timestamp (8 B) + Redis overhead (~50 B) ≈ 100 B.
  • 1M active clients × 2 keys × 100 B ≈ 200 MB. Even 10M clients ≈ 2 GB. Fits one Redis node; shard for throughput, not for memory.
  • Sliding window log instead: 100 req/min × 8 B timestamps × 1M clients ≈ 800 MB per minute of history. This is why the log is only for low-volume limits.

Latency budget

  • Same-AZ Redis round trip ≈ 0.3–0.5 ms. Lua script execution ≈ 10–50 µs. Total ≈ 1 ms, which meets the p99 < 2 ms target with one round trip. Two round trips (read then write) would not, and would also be racy.

Rule storage

  • Thousands of rules, a few hundred bytes each: kilobytes. Cache the whole rule set in every server's memory and refresh every ~30 s.

API design

Internal check API (called by the gateway or middleware)

  • allow(key: string, ruleId: string, cost = 1) → { allowed: boolean, limit, remaining, resetAt, retryAfter? }
  • The key is built from the dimension, e.g. "user:123", "ip:203.0.113.5" or "apikey:abc:endpoint:/search".
  • cost lets expensive endpoints consume more than one token.

Client-facing behaviour

  • 429 Too Many Requests when rejected.
  • Headers on every response: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (epoch seconds). On 429 add Retry-After (seconds).
  • Response body: { error: "rate_limited", retryAfter: 12 }.

Rule management API (operators)

  • PUT /v1/rules/{ruleId} with { dimension, match: { endpoint?, tier? }, limit, windowSeconds, burst?, algorithm }
  • GET /v1/rules, DELETE /v1/rules/{ruleId}.
  • Rules are versioned; servers pull the latest version on a timer or get a push via a config channel.

Notes

  • Never return a 429 without Retry-After: clients will hammer you with immediate retries.
  • Soft-limit mode: log and allow but do not reject. Use it to dry-run a new rule before enforcing.

Data model

Counter state (Redis, ephemeral)

  • Token bucket: key → { tokens: number, lastRefillMs: number }. Store as a hash or two fields packed into one string.
  • Sliding window counter: two keys, "{key}:{currentWindow}" and "{key}:{previousWindow}", each an integer with EXPIRE = 2 × window.
  • Keys expire automatically; nothing needs a cleanup job.

Rule store (durable, tiny)

  • rule_id (PK), dimension (user | api_key | ip), match (endpoint pattern, tier), limit, window_seconds, burst, algorithm, mode (enforce | log), version, updated_at.
  • Lives in a relational DB or a config service (etcd, Consul). Read-heavy, written rarely.

Audit / metrics (append-only)

  • Rejections are emitted as events { key, rule_id, ts } to a stream for dashboards and abuse detection. Do not write these synchronously to a database.

Storage choice

  • Redis for counters: in-memory, single-threaded per shard so Lua scripts run atomically, sub-millisecond, TTLs built in.
  • Memcached would also work for fixed windows (incr + expire) but lacks Lua, so sliding windows and token buckets get racy.
  • A SQL database is wrong here: 100K read-modify-write transactions per second on hot rows is exactly what relational row locks are bad at.

High-level design

Request path
1. The client's request arrives at the API gateway (or a middleware in each service).
2. The gateway's rate-limit middleware builds the key from the request (user id from the auth token, or IP) and looks up the matching rules from its in-memory rule cache.
3. It calls Redis with one atomic Lua script per rule: refill or roll the window, check the count, increment if allowed, return remaining and reset time.
4. Allowed: forward to the backend service and attach X-RateLimit-* headers to the response. Rejected: return 429 with Retry-After immediately; the backend never sees the request.

Control path

  • Operators write rules through a rules service into a durable store.
  • Every gateway instance polls the rules service every ~30 s (or subscribes to a change feed) and swaps its in-memory rule set atomically.

Observability path

  • The middleware emits allow/reject counters to the metrics pipeline and rejection events to a stream for abuse dashboards.

Why the gateway?

  • One implementation, one place to tune. Services stay stateless and simple. Service-specific limits are still possible: the gateway knows the route, so rules can match on endpoint.

Why Redis is on the critical path, and what that costs

  • One extra network hop per request (~1 ms). The alternative, local counters, cannot give a fleet-wide limit. The deep dives cover how to survive Redis being slow or down.

Deep dives

1. Token bucket maths and the atomic Lua script

  • Bucket of capacity B (burst), refilled at r tokens/second. For 100 req/min with burst 20: r = 100/60 ≈ 1.67 tokens/s, B = 20.
  • On each request: tokens = min(B, tokens + (now − lastRefill) × r); if tokens ≥ cost then tokens −= cost and allow, else reject with retryAfter = (cost − tokens) / r.
  • Doing GET then SET from the app is a race: two servers read tokens = 1 and both allow. Put the whole read-compute-write in a Lua script: EVALSHA script 1 key now rate burst cost. Redis runs it atomically on the shard, so the counter is exact.
  • Store tokens and lastRefill in one hash; set EXPIRE to the time it takes to refill fully so idle keys disappear.

2. Sliding window counter with two keys

  • Fixed windows allow 2× bursts: 100 at 0:59 and 100 at 1:01. Sliding window log fixes it exactly but stores a timestamp per request.
  • Approximation: keep a counter for the current window and the previous window. estimated = prev × (1 − elapsedFractionOfWindow) + current. For a 60 s window at 1:15, elapsed fraction is 0.25, so estimated = prev × 0.75 + current.
  • Two INCRs and two EXPIREs, or one Lua script. Memory is two integers per key. Error is bounded and small for uniform traffic; interviewers accept it as the production answer.

3. Fail open or fail closed?

  • Redis unreachable or slow (timeout at ~5 ms): fail open means allow everything and log; fail closed means reject everything.
  • Default to fail open for public APIs: a limiter outage should not become an API outage. Protect the backends with a local fallback limiter (per-server token bucket with limit / N servers) so you are not fully unprotected.
  • Fail closed for security-sensitive limits (login attempts, OTP verification), where over-admission is worse than downtime.
  • Use a circuit breaker: after K consecutive Redis errors, stop calling Redis for a few seconds and use the local fallback, then probe.

4. Rate limit headers and client behaviour

  • X-RateLimit-Limit (the cap), X-RateLimit-Remaining, X-RateLimit-Reset (epoch seconds when the window resets or the bucket is full). On 429: Retry-After in seconds, rounded up.
  • Clients should back off with jitter; document it. Without Retry-After, a mobile fleet retrying every second turns a limit into a self-inflicted DDoS.
  • Return the headers on allowed responses too, so well-behaved clients can pace themselves and never see a 429.

5. Multiple dimensions, hot keys and local pre-aggregation

  • Limit by user and by IP at once: evaluate each rule, allow only if all pass, and decrement all. If the second rejects after the first incremented, you over-counted by one; accept it or run both in one Lua script with both keys on the same shard (use hash tags like {user:123}).
  • Hot keys: one abusive client at 50K req/s hits one Redis shard. Mitigation: once a key is over its limit, cache the rejection locally for the rest of the window (the gateway knows resetAt), so Redis is not asked again.
  • Pre-aggregation for very high limits (e.g. 1M/min for a partner): each gateway batches its local count and flushes to Redis every 100 ms with INCRBY. Accuracy drops to ±(servers × flush interval × rate) but Redis load drops 10–100×.

6. Distributed clocks and window boundaries

  • Use Redis TIME inside the Lua script as the clock, not each server's clock, so all servers agree on window boundaries and refill maths.

Trade-offs & wrap-up

Summary of decisions

  • Sliding window counter by default, token bucket where bursts are a feature; both as atomic Lua scripts in Redis.
  • Centralised Redis for accuracy, with a local per-server fallback limiter and a circuit breaker for availability.
  • Fail open for public APIs, fail closed for auth-sensitive endpoints.
  • Enforcement at the gateway with rules cached in memory and refreshed every 30 s.
  • Full X-RateLimit-* and Retry-After headers on every response.

What was given up

  • Exactness: the sliding window counter is an approximation, and multi-dimension checks can over-count by one.
  • Rule changes take up to 30 s to apply everywhere.
  • One extra hop on every request.

With more time

  • Per-client dynamic limits driven by abuse scores, global quotas over days/months in a separate accounting system, and a dry-run mode that reports what each new rule would reject before enforcing it.