Design a globally distributed rate limiter
The interviewer asks
“Your API runs in four regions (Mumbai, Hyderabad, Singapore, Frankfurt) and includes LLM-backed endpoints. Design a rate limiter that enforces per-tenant limits globally, both on requests and on LLM tokens (e.g. 'tenant T may use 1M tokens per minute across all regions'), adds under 1 ms of latency, and behaves predictably when a region's Redis is slow or regions lose contact with each other.”
What a strong answer looks like
A strong answer accepts up front that a strictly exact global limit and sub-millisecond checks are incompatible: a cross-region round trip is 30-150 ms. So it decides per limit how much accuracy is needed and designs for 'approximately global': each region enforces locally against its own Redis (token bucket in a Lua script, cost-weighted for LLM tokens), and regions coordinate asynchronously, either by splitting the global quota into regional shares that are rebalanced from observed demand every few seconds, or by gossiping regional counts and subtracting the others' usage. Overshoot is bounded and quantified (sync interval × rate). Token limits need reservations, because the true cost is only known after the LLM response: reserve an estimate up front, then reconcile with actual usage. Failure behaviour is explicit per limit: fall back to a local in-process limiter at limit ÷ instances when Redis is unreachable, fail open for general quotas, fail closed for abuse-sensitive ones, and during a region partition each region keeps its last share. Senior candidates add a 5 ms Redis timeout with a circuit breaker, Retry-After headers, and testing accuracy under load with fault injection.
Concepts to mention
1 Requirements
Functional
- Per-tenant global limits on requests per second/minute and on LLM tokens per minute and per day
- Per-tenant limits per region and per endpoint as well (e.g. max 200 concurrent streaming requests)
- Cost-weighted limits: one LLM call consumes tokens proportional to prompt + completion size
- Return 429 with Retry-After and RateLimit-Limit/Remaining/Reset headers
- Change limits at runtime (plan upgrade takes effect within 30 s)
- Per-limit failure policy: fail open, fail closed, or local fallback
- Usage visibility: per tenant per region consumption and 429 counts
Non-functional
- Check latency p99 under 1 ms in-region (no cross-region call on the request path)
- Global accuracy within ~5% of the limit under steady load; bounded, documented overshoot during bursts and partitions
- Scale: 400k checks/s across regions, ~100k tenants, ~2M token-bucket keys
- A Redis or inter-region link failure must never cause an API outage by itself
- Clock-skew safe: no correctness dependence on app-server wall clocks
Out of scope (say it out loud)
- Network-layer DDoS protection (CDN/WAF)
- Monthly billing quotas and invoicing (use the same usage events, but separate system)
- Bot detection
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
Library calllimiter.Acquire(ctx, tenant, resource, cost) → {allowed, remaining, retry_after, reservation_id} | In-process library in each API and LLM-gateway instance; one pipelined Lua call to the regional Redis, 5 ms timeout. |
Library calllimiter.Settle(reservation_id, actual_cost) | After the LLM response, adjust the bucket by actual − reserved tokens (refund or extra charge). |
gRPCQuotaSync.Exchange(region, [{key, consumed_delta, window}]) | Coordinator-to-coordinator stream between regions every ~1 s; idempotent by (region, seq). |
PUT/admin/v1/limits/{tenant} | Set limits (requests/min, tokens/min, tokens/day, concurrency) and failure policy; published to all regions. |
GET/v1/usage/limits | Customer-facing: current global usage and limits per resource. |
4 Data model
regional bucketsRedis Cluster per regionkey rl:{tenant}:{resource}hash {tokenslast_refill_msregional_capacityremote_used}Braces put all keys for a tenant on one shard so a multi-key Lua script works. Lua uses redis.call('TIME') so app clocks do not matter. TTL 2× window.
regional shareRedis (written by coordinator)share:{tenant}:{resource} = capacity this region may grant per windowUpdated every few seconds from demand; read by the Lua script as the bucket's refill rate.
reservationsRedisres:{id} = {tenantresourcereservedts}TTL 5 minSettle adjusts by the difference; expired reservations are settled at the reserved amount (safe default).
limits configPostgres (global) → pushed via config servicetenant_idresourcelimitwindow_sburstfail_moderegions_allowedversionEach region caches the full config in memory in coordinators and limiter libraries.
usage eventsKafka per region → ClickHousetsregiontenantresourcecostdecisionFor dashboards, tuning shares and billing reconciliation; not on the request path.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
LLM call checked against a global token limit
- 1Tenant T (limit 1M tokens/min globally) sends a chat request to the Mumbai region. The request asks for max_tokens 2,000 with a 3,000-token prompt.
- 2The LLM gateway's limiter estimates cost = 3,000 prompt + 600 (p90 output for this feature) = 3,600 tokens and calls one Lua script on the Mumbai Redis shard for {T}.
- 3The script reads Redis TIME, refills T's bucket at Mumbai's current share (say 55% of 1M/min because 55% of T's demand is in Mumbai), subtracts remote usage reported by the coordinator, and atomically takes 3,600 if available.
- 4Allowed: it stores a reservation and returns remaining tokens. The whole check takes ~0.3-0.6 ms.
- 5The response streams back; the provider reports actual usage of 3,000 + 410 tokens. Settle refunds 190 tokens to the bucket.
- 6A usage event goes to Kafka; every second the Mumbai coordinator sends T's consumed delta to the other three regions and receives theirs.
- 7If T's traffic shifts toward Singapore, the coordinators move share there within a few seconds; if T exceeds its global limit, all regions return 429 with Retry-After computed from the refill rate.
Mumbai Redis becomes unreachable
- 1Limiter calls start timing out at 5 ms; the client-side circuit breaker opens after 5 consecutive failures in 1 s.
- 2For limits marked local_fallback, each instance switches to an in-process token bucket with capacity = regional share ÷ number of instances (known from service discovery, e.g. 1/40).
- 3Limits marked fail_open (general read APIs) allow traffic but emit a 'limiter degraded' metric; limits marked fail_closed (OTP sends, API-key creation) reject with 503.
- 4The breaker probes Redis every few seconds; when it recovers, the library returns to the shared buckets. Some overshoot or undershoot during the outage is accepted and visible on the dashboard.
7 Deep dives
Why not one global counter, and what to do instead
The obvious design is a single Redis in one region that every region calls. It is exact, but a Frankfurt request pays ~120 ms per check and every region depends on one region's availability, so a Mumbai outage turns into a worldwide rate-limiting outage. Strongly consistent global stores (Spanner, CockroachDB, DynamoDB global tables with conditional writes in one home region) have the same latency problem. The practical answer is local enforcement with asynchronous coordination. Option 1, quota splitting: the global limit L is divided into regional shares, initially by recent demand (e.g. Mumbai 55%, Hyderabad 25%, Singapore 15%, Frankfurt 5%); each region enforces its share exactly and locally. Coordinators recompute shares every few seconds from observed demand plus a small minimum floor, so a region with a sudden burst gets more within seconds. Option 2, shared view: each region enforces against the full limit but subtracts the latest known usage of other regions (a grow-only counter per region per window, merged like a CRDT G-counter). Option 2 adapts instantly to traffic shifts but overshoots by up to rate × sync interval × other regions; option 1 never overshoots but can wrongly reject when demand moves faster than rebalancing. Many systems combine them: shares as the base, plus borrowing from peers' unused share.
Token-based limits when cost is only known later
Request limits are easy because each request costs 1. LLM token limits are harder: the prompt size is known before the call, but the output size is only known when the response ends, which may be 30 seconds later, and streaming responses can be cancelled midway. If you only count after the response, a burst of 500 parallel requests all pass the check before any of them is charged. If you reserve max_tokens, you reject legitimate traffic, since max_tokens is often 4-10x the real output. The usual pattern is reserve-then-settle: at admission, reserve prompt tokens plus an estimate of the output (the p90 output for that feature or model, or a fraction of max_tokens), which bounds concurrency honestly. When the provider returns usage (in the final streaming chunk), settle: refund the unused part or charge the extra, which can push the bucket negative, so the next requests wait. Reservations carry a TTL so a crashed gateway does not leak capacity forever. Also add a concurrency limit (max in-flight streams per tenant), because tokens per minute alone does not stop one tenant from opening 5,000 slow streams. Always bill from the provider's reported usage, not your estimate.
Failure modes: Redis slow, Redis down, regions partitioned
A rate limiter is on the path of every request, so its failures must be designed, not discovered. Slow Redis is worse than dead Redis: if the limiter waits 200 ms per check, the API slows down for everyone. So every call has a strict timeout (2-5 ms), and a circuit breaker stops calling Redis after repeated failures. What to do then is a policy per limit, documented in an ADR. Fail open (allow) protects availability but removes protection, acceptable for general quotas over a few minutes. Fail closed (reject) is right where abuse costs money or security: OTP sends, login attempts, payouts. The common middle ground is a local in-process token bucket per instance, with capacity = share ÷ instance count, which keeps the worst abuse in check without any network. When regions lose contact with each other, each region keeps enforcing its last known share and coordinators stop moving shares; since the shares sum to the global limit, the global total cannot exceed L even during the partition, but a tenant whose traffic shifts may be limited too early. With the shared-view approach a partition instead causes overshoot, bounded by each region's own share. Make the system observable: fallback-mode count per region, overshoot estimate per tenant, sync lag.
8 Trade-offs
Quota splitting vs shared counters
Splitting guarantees the global sum and keeps each check purely local, but may reject traffic that moves quickly between regions. Shared counters with gossip follow traffic instantly but overshoot during bursts and partitions. Use splitting for hard limits tied to cost (LLM token budgets tied to a provider contract) and shared counters for soft fairness limits.
Library in each service vs central rate-limit service
A central service per region (like Envoy's global rate limit service) keeps logic in one place but adds a network hop. A library that talks to Redis directly saves the hop and is easier to keep under 1 ms, but must be maintained in every language. With most traffic through a gateway and an LLM gateway, an in-gateway library is the pragmatic choice.
Token bucket vs GCRA vs sliding window
Token bucket allows controlled bursts and naturally supports cost-weighted takes; GCRA stores just one timestamp per key and is equivalent in behaviour (used by redis-cell); sliding window counters suit 'N per minute' quotas. For token budgets per minute plus burst control, token bucket with lazy refill in Lua is the simplest correct choice.
9 Bottlenecks
Huge tenant on one Redis shard
A tenant doing 50k checks/s overloads its shard. Batch locally: each instance takes a lease of N tokens (e.g. 1% of its share) from Redis and spends it in memory, returning unused tokens on expiry. This cuts Redis calls by 10-100x at the cost of small inaccuracy.
Too many keys to sync
Syncing every tenant every second does not scale to millions of keys. Sync only active keys (touched in the window), compress deltas, and sync large tenants more often than small ones whose overshoot does not matter.
10 Failure handling
Coordinator in one region crashes
That region keeps enforcing its current shares (stored in Redis), which remain valid. Shares stop adapting until a standby coordinator takes over via leader election (a lease in Redis or etcd); no request-path impact.
Clock skew between regions
Each Lua script uses its own Redis server time, and sync messages carry window ids and sequence numbers rather than wall-clock comparisons, so skew of a few hundred milliseconds only shifts window boundaries slightly.
Bad limit pushed (tenant limit set to 0)
Validate config (floors per plan), roll out new limits in shadow mode first (log would-be 429s), and keep the previous version for instant rollback.
11 What impresses
- Quantifies cross-region RTT and rejects a synchronous global counter with numbers
- Gives the overshoot formula and shows it is about 5% for a realistic tenant
- Handles the 'cost known only after the response' problem with reservations and settlement
- Explains what each design does during a region partition and why quota splitting cannot exceed the global sum
- Treats slow Redis as worse than down Redis and adds a 2-5 ms timeout
- Mentions local leases to cut Redis load for hot tenants
12 Mistakes
- Calling a single global Redis from all regions on every request
- Counting LLM tokens only after the response, letting parallel bursts through
- Using app-server wall clocks for windows
- Read-then-write from the application instead of an atomic script
- No timeout on the limiter call, turning a Redis slowdown into an API outage
- Not deciding fail-open vs fail-closed per limit
13 Follow-ups
Lessons this design uses
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.