Ecommet
Case study

Design a Rate Limiter Service

Design a rate limiter that protects every API in a company: per-client and per-endpoint rules, shared counters across many gateway instances, low added latency, and sensible behaviour when the counter store fails.

Intermediate 5 min read 3 quiz questions

Jump to the quiz

In a nutshell

  • Enforce limits at the gateway, with rules loaded from configuration and counters in a shared, fast store.
  • Use a token bucket or sliding window counter, updated atomically (for example a Redis Lua script).
  • Keep added latency to about one round trip to the counter store; batch or approximate for very hot keys.
  • Decide fail-open vs fail-closed when the store is down, and always return 429 with Retry-After.

Requirements

  • Functional: limit requests per key (API key, user, IP) per rule (for example 100 per minute on /search, 5 per minute on /login); reject excess with 429; rules change without deploys.
  • Non-functional: adds at most a few milliseconds; accurate within a small margin across all gateway instances; must not take the API down if it fails.

Estimates

Assume 50,000 requests per second at peak across 20 gateway instances and 10 million active keys. Each check is one small read-modify-write in the counter store: 50,000 operations per second, well within a small Redis cluster. A token bucket per key needs about 16–32 bytes of data plus key overhead: on the order of a gigabyte for 10 million keys.

API

An internal check call and a rule definition

Example
allow(key="api_key:k_123", rule="search") -> { allowed: true, remaining: 57, reset_in_s: 41 }

rules:
  - name: search    match: "GET /search"   limit: 100 per 60s   key: api_key
  - name: login     match: "POST /login"   limit: 5 per 60s     key: ip

Rejected response: 429 Too Many Requests, Retry-After: 41, X-RateLimit-Remaining: 0

Data model

Per (rule, key): either two numbers for a token bucket (tokens, last_refill) or two counters for a sliding window counter (current and previous window). Stored in Redis with a TTL slightly longer than the window, so idle keys disappear on their own.

High-level design

Rate limiting at the gateway

Rate limiting at the gateway Clients → Gateways: request Gateways → Redis cluster: atomic check Gateways → APIs: allowed Rules config → Gateways: rules ClientsGatewayslimiter middlewareRedis clustercountersRules configAPIsrequestatomic checkallowedrules

All steps Every gateway instance checks the same counters. Rules come from a configuration service and are cached locally.

Diagram as text
  1. A request reaches any gateway instance.
  2. The limiter runs one atomic script in Redis for the request's key and rule.
  3. If allowed, the request continues to the API; if not, the gateway returns 429.
  4. Rules are pushed or polled from configuration and cached in each gateway.

Deep dives

Atomic updates

Reading the counter, deciding and writing back in separate calls races under concurrency. Run the whole token-bucket update as one Lua script in Redis (scripts run atomically), or use INCR with EXPIRE for fixed windows. The algorithms and tested code are in the Rate limiting topic.

Hot keys and latency

A single huge client can make one Redis key hot. Options: shard keys across the cluster (keys naturally spread by hash slot), keep a small local token allowance per gateway that is topped up from Redis in batches (slightly less exact, far fewer calls), or give the biggest clients dedicated limits.

When Redis is down

Fail open (allow) keeps the product working but removes protection; fail closed (reject) protects the backend but causes an outage. A practical middle: fail open with a conservative local in-memory limit per gateway as a backstop, and alert.

Bottlenecks and trade-offs

ChoiceGainCost
Central countersExact global limitsA network hop per request
Local counters onlyZero added latencyLimit multiplied by the number of instances
Local allowance + periodic syncFew calls, nearly exactShort bursts can exceed the limit slightly

The last five minutes

Common mistakes

  • Per-instance counters presented as a global limit.
  • Non-atomic read-then-write counter updates.
  • No plan for the counter store failing.
  • Limiting only by IP, punishing users behind shared networks.

Interview questions

Try answering out loud before you open each one.

How do you enforce limits across multiple regions?

Either keep a separate budget per region (simple, each region enforces a share of the limit), or sync counters between regions asynchronously and accept small overshoots. A single global counter store adds cross-region latency to every request, which is rarely worth it.

Check yourself

1. Why run the token bucket update as a Lua script in Redis?
Show the answer

Answer: So read, decide and write happen atomically. Separate commands race between gateway instances.

2. Redis is down. Which option keeps the API up while still offering some protection?
Show the answer

Answer: Fail open with a local per-instance limit. A local backstop limits damage until Redis returns.

3. Which header tells a limited client when to retry?
Show the answer

Answer: Retry-After. Sent with 429 (or 503).

Further reading

Your notes

Saved in this browser as you type.

Finished this topic?

Mark it read to track your progress, then carry on with the next one.

Next topic