Distributed rate limiting: local limits vs a central Redis counter, simulated
A rate limit is easy on one server: keep a counter. With a fleet of API gateways behind a load balancer, each sees only part of a tenant’s traffic, and the limit belongs to the tenant as a whole. Distributed rate limiting is the choice of where the count lives: split across the gateways, in one shared store such as Redis, or kept locally and shared now and then.
The playground limits one tenant to 100 requests a second across several gateways and runs the same traffic through all three designs. Try uneven load balancing, bursts and different sync intervals.
One tenant may make 100 requests a second in total. It sends 300 requests a second, spread evenly, across 4 gateways. Local limits give each gateway 25 a second, which works here because the load balancer spreads requests evenly. The central counter is exact, at the price of a Redis call on every request, 313 a second, each adding about 1 ms. Syncing every 100 ms costs 40 Redis calls a second, but between syncs each gateway only knows its own count, so 226 requests beyond the limit get through.
- Let through, per second
- 100
- Beyond the limit
- 0
- Rejected while under it
- 0
- Redis calls a second
- 0
- Added per request
- nothing
- Let through, per second
- 100
- Beyond the limit
- 0
- Rejected while under it
- 0
- Redis calls a second
- 313
- Added per request
- 1 ms
- Let through, per second
- 123
- Beyond the limit
- 226
- Rejected while under it
- 0
- Redis calls a second
- 40
- Added per request
- nothing
Three designs, three failure modes
- Local limits give each of 4 gateways a quarter of the limit. With traffic spread evenly that is exact and free. But load balancers are rarely even, especially with long-lived connections: when 55% of a tenant’s 81 requests a second land on one gateway, it runs out while the others sit idle, and 189 requests are rejected although the tenant never exceeded its limit. Every time the fleet scales, every share changes.
- A central counter in Redis is exact whatever the spread: every request increments the tenant’s counter for the current second and is allowed if the count is within the limit. It costs a round trip on every request, about a millisecond inside a data center, and a Redis operation for every request, 313 a second here, and Redis becomes a dependency of every call.
- Local counts with periodic sync cut Redis traffic: each gateway counts on its own and adds its count to Redis every 100 ms, 40 calls a second instead of 313. Between syncs each gateway knows only its own count, so the fleet lets 123 requests a second through a limit of 100, and during a burst 220. Syncing once a second lets nearly 300 through; syncing every 10 ms is almost exact but makes 400 Redis calls a second, more than the central counter.
In practice the central counter is the default: a Redis call per request is cheap next to the request itself, and one Redis shard handles tens of thousands of operations a second. The sync design suits very high volumes where a few percent of overshoot is acceptable. Either way, gateways can remember locally that a tenant is blocked until the current window ends and skip Redis until then.
Details that matter
- Atomicity. Read-then-write races let two gateways both see 99 and both allow. Use
INCR, which returns the new count, or a Lua script for token buckets and sliding windows. - When Redis is down. Decide in advance whether to fail open, allowing traffic and protecting availability, or fail closed, protecting the backend. Many fall back to local limits.
- Scale. Key counters by tenant, so Redis Cluster spreads tenants across shards; one very hot tenant can still need its own handling.
- Where to enforce. At the edge for abuse, per tenant at the API gateway, and with load shedding inside services for overload that no per-tenant limit predicts.
A fixed-window limiter on Redis
import time
import redis
r = redis.Redis()
def allow(tenant: str, limit: int = 100) -> bool:
window = int(time.time()) # one counter per tenant per second
key = f"rl:{tenant}:{window}"
pipe = r.pipeline() # MULTI/EXEC: both or neither
pipe.incr(key)
pipe.expire(key, 2) # old windows delete themselves
count, _ = pipe.execute()
return count <= limitIn a system design interview
Say where the count lives and why: “Limits are per API key, enforced in the gateway. Each request does an atomic INCR on a Redis key for the key and the current second, about half a millisecond. Redis is sharded by API key and replicated; if it is unreachable, gateways fall back to a local limit of the global limit divided by the number of gateways, so we degrade to approximate rather than unlimited.” Expect the follow-ups: the boundary burst of fixed windows, hot tenants, and limits across regions.
Read the guide to rate limiting algorithms or API gateways, or watch a strong candidate design a rate limiter.
Questions people ask
How do you implement rate limiting across multiple servers?
Keep the count where every server can see it. The common design is a counter per client and time window in Redis, incremented atomically on every request, with the request allowed if the new count is within the limit. Alternatives split the limit across servers or count locally and sync periodically, trading accuracy for fewer Redis calls.
Why not divide the limit by the number of servers?
Because traffic is rarely spread evenly. With long-lived connections or sticky load balancing, one server can see most of a client’s requests and reject them while the others have unused allowance, so clients under their limit get errors. The shares also have to change every time the fleet scales.
Is Redis fast enough for rate limiting?
Usually yes. An INCR is a sub-millisecond round trip inside a data center and one Redis shard serves tens of thousands of operations a second; sharding counters by client key spreads the load across a cluster. For extreme volumes, gateways batch counts locally and sync periodically, accepting some overshoot.
What happens to a distributed rate limiter when Redis is down?
You choose. Failing open allows all traffic, protecting availability but not the backend. Failing closed rejects traffic, protecting the backend at the cost of an outage. A common middle ground falls back to local per-server limits until Redis returns.
Why must the counter update be atomic?
If a server reads the count, checks it and then writes it back, two servers can read the same value and both allow a request, letting traffic exceed the limit. Redis INCR returns the incremented value atomically, and Lua scripts make multi-step algorithms such as token buckets atomic too.
How much does syncing local counters periodically overshoot?
Between syncs each server only knows its own count, so the fleet can admit roughly the limit plus what every other server allows before the next sync. In the simulation, four gateways syncing every 100 ms let about 23% too many requests through under steady overload and more than twice the limit during bursts.
Where should rate limiting be enforced?
As early as possible for abuse, at the CDN or edge; per client or API key at the API gateway, where identity is known; and inside services as load shedding for overload that per-client limits do not predict. Each layer protects against a different problem.