Index/Distributed Rate Limiter

SponsorGitHub
Design13 min

Distributed Rate Limiter

One global limit enforced across a fleet of stateless API servers, at a million requests per second.

Primary concepts and the hard part

Concepts
token bucket / sliding window algorithmsdistributed countersRedis atomicity (Lua scripts)latency-vs-accuracy tradeAPI gateway placement429 semanticsgraceful degradation
The hard part they’re probing
Enforcing one global limit across N stateless API servers without adding a synchronous Redis round trip to every single request — and knowing what happens when the limiter's own datastore is down.

Requirements

Functionalwhat it must do
Limit requests per identity (user ID, API key, IP) over a time window
Support multiple tiers (free: 100/min, pro: 10,000/min)
Support per-endpoint limits (expensive endpoints get tighter caps)
Return 429 with Retry-After and remaining-quota headers
Rules configurable at runtime, no deploy
Non-functionalhow well it must do it
Added latency < 5ms p99 — the limiter sits in front of everything
Must not become a single point of failure
Accurate enough: brief small overshoot acceptable, 10x overshoot not
Horizontally scalable with the API tier
Out of scopedeliberately left out
DDoS mitigation at L3/L4 (that's upstream, at the CDN/scrubbing layer)
Billing
Scale
1M requests/sec at the edge
Identities tracked: ~50M active keys
State per identity: ~50 bytes → 2.5 GB, fits comfortably in Redis memory
Conclusion
State is small and hot. Redis is the obvious store; the design question is how often you talk to it.

API / Model

Internal checkcalled by the gateway, not a public API
FN
allow(identity, endpoint)
{allowed: bool, remaining: int, reset_at: ts, retry_after: int}
Rule configuration
PUT
/admin/limits
{scope, identity_tier, endpoint_pattern, limit, window_sec, burst}
200OK
Over the limit
429
Too Many Requests
sent with the X-RateLimit-* and Retry-After headers
primary key

TTL-based expiry is worth pointing out: the counters garbage-collect themselves, so there's no cleanup job and memory is bounded by active identities, not total identities.


High-level architecture

DatabaseCacheExternal systemAPI gatewayLoad balancerFocusClick a node for details

Limits are enforced in two layers inside the API gateway fleet: an L1 local bucket in every gateway, and a Redis cluster that holds the shared count. Volumetric attacks are dropped before traffic reaches either layer.

  1. Client traffic first passes CDN L3/L4 scrubbing, and the Load balancer spreads what is left across the gateways.
  2. The gateway finds the rule for the caller's identity and endpoint in its local copy of the Config service rules.
  3. It checks the caller's L1 local bucket in memory, with no network call.
  4. Every N requests or X milliseconds, the L2 batched sync reconciles that bucket with the Redis cluster, which is sharded by identity. A Lua script there runs the check-and-decrement atomically in one round trip, so every gateway works from the same global count.
  5. If the bucket, as of its last sync, has a token, the request is allowed and goes to Backend services. If not, the gateway returns 429 Too Many Requests with Retry-After and the X-RateLimit-* headers.

Two flows run beside the request path. The Config service distributes rule changes to the gateways, which cache them and fall back to safe defaults when it is unreachable, so a limit can change without a deploy. The dotted edge from Redis to Backend services is the failure path: when Redis is unreachable, gateways keep enforcing their local buckets, let traffic through, and log and alert.


Trade-offs and deep dives

01
Algorithm choice — know all four, recommend one
text
FIXED WINDOW            limit 100/min
 [10:00:00─10:00:59] ████████████ 100
 [10:01:00─10:01:59] ████████████ 100
                    ▲
        100 at 10:00:59 + 100 at 10:01:00
        = 200 in one second. BOUNDARY BURST BUG.

SLIDING WINDOW LOG      exact, stores every timestamp
 [t1 t2 t3 ... t100]  ← memory grows with traffic. Accurate but costly.

SLIDING WINDOW COUNTER  the practical compromise
 count = curr + prev × (fraction of prev window still in view)
 ~exact, O(1) memory.

TOKEN BUCKET            ← recommended default
   capacity B = 100 (burst), refill R = 10/sec
   ┌──────────────┐
   │ ● ● ● ●      │ ← refills at R
   └──────┬───────┘
          │ 1 token per request
          ▼  empty → reject
 Allows bursts up to B, enforces long-run average R.
 Real traffic IS bursty; idle clients should be allowed to catch up.

Why token bucket wins in most interviews: it matches real traffic shape, needs only two numbers of state (tokens, last_refill_ts), and refill is computed lazily on access rather than by a background timer. Use leaky bucket instead only when the downstream genuinely cannot absorb bursts (a third-party API with a hard cap), because leaky bucket smooths output completely at the cost of queueing latency.

02
Distributed state — the actual design decision
ApproachLatencyAccuracyNotes
Centralized Redis per request+1-2msexactRedis in the path of every request; a Redis blip is an outage
Local counters, quota divided by N0mspoorUnfair under uneven load balancing; breaks when N autoscales
Two-tier (local + async sync)~0ms typicalgoodRecommended: local bucket absorbs the common case, syncs to Redis on a batch/threshold
Gossip between gateways0mseventualComplex, rarely worth it

The two-tier answer in detail: each gateway holds a local allowance and decrements it locally with zero network cost. It syncs with Redis every N requests or every X milliseconds, reconciling its local view with the global count. Overshoot is bounded by (number of gateways × sync batch size) — a number you can compute and state. This buys you near-zero added latency and removes Redis from the hot path, at the cost of small, bounded inaccuracy. For a 100/min limit, brief overshoot to 105 is fine. For a "1 free trial per account" limit, it is not — so use exact centralized checks for correctness-critical limits and two-tier for traffic-shaping limits. Making that distinction is the senior move.

03
Why the Lua script matters

GET then SET is a read-modify-write race: two gateways both read 5 tokens, both decrement, both write 4, and two requests consumed one token. Redis executes Lua scripts atomically on its single thread, making check-and-decrement one indivisible operation and one round trip.

04
Fail open or fail closed?

If Redis is down: failing closed rejects all traffic and turns a limiter outage into a total outage. Failing open lets traffic through unlimited and risks overwhelming backends. The usual answer is fail open on the local bucket — keep enforcing the per-gateway local limit so you're not completely unprotected, serve traffic, and alert loudly. State the choice and the reasoning; interviewers care more about the reasoning than the choice.

05
Sharding Redis

Hash by identity, so all state for one key lives on one node and the Lua script is a single-node atomic operation. Cross-slot operations would break atomicity — a good detail.

06
Hot keys

One enormous customer's key concentrates on one Redis shard. Either give large tenants dedicated shards, or shard their key into sub-buckets (key#0..#9, each with 1/10 the limit) and pick one at random per request.

07
What to limit by, and the trap

Per user ID is fairest but requires authentication, so the authentication endpoint itself must be limited by IP. IP limiting is blunt: corporate NAT means thousands of users share an IP, and attackers rotate proxies. The realistic answer is layered limits — IP at the edge, API key at the gateway, user ID at the service, plus per-endpoint overrides for expensive operations.


Possible follow-up questions

6 questions·try answering before you reveal