Skip to main content
In production, API gateways are usually deployed as multiple instances behind a load balancer so requests don’t depend on a single gateway. A single gateway is a single point of failure and does not scale. However, naive local in-memory rate limits break in this distributed setup. Consider two gateways (A and B) and a user (Alan). The load balancer routes some of Alan’s requests to A and some to B. Each gateway only sees the requests routed to it and keeps a separate in-memory token bucket for Alan. That effectively gives Alan multiple buckets (one per gateway) and lets him exceed the intended global limit. Add a third gateway and he can make even more calls. In short: local in-memory counters lie in distributed systems. The reliable solution is to move buckets out of gateway memory into a single shared, fast store (for example, Redis) so all gateways read and update the same bucket for a given user. This centralization solves the multiple-buckets problem — but introduces another challenge: concurrent access and race conditions.

Why concurrency breaks naive shared counters

Accessing a token bucket involves three logical steps:
  1. Read remaining tokens and the last-update timestamp.
  2. Compute tokens to add based on elapsed time.
  3. Write the new token count and timestamp.
If two gateways run these steps concurrently against the same Redis key, they can both read the same remaining tokens and both allow requests that should have been blocked. The read–compute–write sequence must be atomic to avoid this race.

Atomicity with Redis Lua scripts

Redis supports running Lua scripts atomically: while a script executes, no other client can mutate the keys it touches. Encapsulate the read–compute–write sequence in a single Lua script and run it with EVAL/EVALSHA. This guarantees correctness no matter how many gateways you have. Below is a compact, practical Lua script that implements a token-bucket check and update atomically. It assumes each bucket is a Redis hash with fields tokens and ts (timestamp in milliseconds).
A gateway should call this script for every API request. The script returns a three-element array:
  • allowed — 1 if allowed, 0 if rate-limited.
  • remaining tokens — the new token count.
  • retry_after — milliseconds until the next token (0 when allowed is 1).
Note: Use integer millisecond timestamps (now) to avoid floating-point drift in production.
Decide and document behavior when the shared store is unavailable: do you fail open (allow traffic) or fail closed (reject traffic)? This is a critical trade-off—prioritize availability for general APIs and stricter protection for security-sensitive endpoints (for example, login or payment flows).

Operational considerations and trade-offs

Quick parameter examples

For security-critical APIs (e.g., login, password reset, anti-abuse paths), prefer failing-closed on store outages. For general product APIs where availability is more important, failing-open might be acceptable. Document and test your chosen mode.

Deployment and scaling recommendations

  • Run the Lua script via EVALSHA (load script once, call by SHA) to reduce overhead.
  • Keep Redis instances in the same availability zone or region as your gateways to minimize latency.
  • Monitor Redis operation latency, script execution time, and ops/s. Alert on degraded performance.
  • If you need higher throughput, shard keys by user ID across a Redis Cluster or partition users by consistent hashing.
  • Use replication and automated failover (Sentinel or managed Redis offerings). If you need cross-region failover, evaluate read/write latency and consistency implications.

Interview-friendly summary (talking points)

  • Put the rate limiter at the API gateway so you reject work early.
  • Count per account for authenticated users and per IP for anonymous traffic.
  • Use the token-bucket algorithm: capacity = burst, refill = sustained rate. Example: capacity 50; refill 1.67 tokens/s.
  • Store buckets centrally in Redis so all gateways operate on the same counters.
  • Make the check and update atomic by running the logic inside Redis as a Lua script. This prevents races when multiple gateways access the same bucket.
  • Return 429 Too Many Requests with a Retry-After header when rate-limited.
  • Decide and document fail-open vs fail-closed behavior for Redis outages based on endpoint sensitivity.
  • Key takeaway: local counters lie in distributed systems — authoritative counters must live in a shared store and be updated atomically.
When you need to guarantee a single resource is not double-assigned (concert seat, driver dispatch, etc.), apply the same pattern: keep authoritative state in one shared place and perform atomic updates. References and further reading:
A dark-themed schematic of a ride-booking system showing a mobile app on the left, a load balancer routing requests to two services (labeled Leo and Maya), which connect to an app server and a database. The top shows numbered tabs for stages like "Clarify," "Estimate," and "High-level design."

Watch Video