System design / HLD + LLDAdvanced · 9 sections · 4 figures · 15 min read

Distributed Rate Limiter

  • Redis
  • Algorithms
  • Atomicity
  • Designed · build in progress
The constraint

One limit must hold across 50 instances, cost under a millisecond, and behave predictably when Redis fails.

The decision

A token bucket in one atomic Redis Lua script on Redis time, with fail-open or fail-closed declared per policy.

75k/s
limiter checks at peak
0
over-admissions from races
2 ms
p99 target per check

1. Problem and requirements

Protect APIs from overload and abuse, and enforce plan quotas fairly, across every instance of every service.

Example policies

Policy Key Limit On store failure
Public API, free plan API key 100 / minute, burst 20 Fail open (local limiter)
Public API, paid plan API key 1,000 / minute, burst 200 Fail open (local limiter)
Login IP + username 10 / minute, strict rolling window Fail closed
Payment initiation User 5 / minute Fail closed
Tenant-wide ceiling Tenant 20,000 / minute across all its keys Fail open (local share)
Search endpoint API key + endpoint 30 / second Fail open

Functional requirements

# Requirement
F1 Decide allow or reject for a request in line, before the handler runs.
F2 Keys can combine identity dimensions: user, API key, IP, tenant, endpoint.
F3 Policies set capacity (burst) and sustained rate, per plan.
F4 A request can be subject to several policies at once (key limit and tenant ceiling); it is allowed only if all allow it.
F5 Rejections return 429 Too Many Requests with an accurate Retry-After. Every response carries remaining-quota headers.
F6 Policies change without restarting services.

Non-functional requirements

# Requirement Target
N1 Decision latency added to a request p99 under 2 ms, p50 under 0.5 ms
N2 Enforcement accuracy under concurrency A key never exceeds its limit when Redis is healthy (no over-admission from races)
N3 Behaviour when Redis is unavailable Defined per policy: open, closed or local
N4 Memory per active key Under 100 bytes
N5 Availability of the limiter path Never the reason an API is down, except for fail-closed policies

What “strict” means here

Strict enforcement means no over-admission caused by concurrency: two simultaneous requests can never both take the last token. It does not mean perfect rolling-window precision. Token buckets allow a burst up to capacity by design. Where a rolling-window guarantee is required (login), a different algorithm is used (section 4).

2. Capacity estimation

Assumptions: 50 instances across services, 50,000 requests/s at peak, 1.5 policy checks per request on average (most requests check one policy, some check a key limit and a tenant ceiling), 2 million keys active in any hour.

Metric Working Result
Redis script calls at peak 50,000 × 1.5 75,000 calls/s
Network round trips One per request (all policies for a request go in one script call when their keys share a slot, otherwise pipelined) ≈ 50,000 round trips/s
Memory per bucket Hash with two fields plus key name and overhead ≈ 90 bytes
Memory for active keys 2,000,000 × 90 B ≈ 180 MB
One Redis shard A short Lua script typically runs in tens of microseconds; one shard sustains tens of thousands of such calls per second Shard the keyspace from the start: 4 primaries
Latency budget Same-zone round trip 0.2–0.5 ms + script time p99 well under 2 ms when not overloaded

These are planning estimates. Script throughput per shard must be measured on the target hardware (see validation) before fixing the shard count.

3. Architecture and APIs

High-level architecture

FIG 01ArchitectureHigh-level architecture

Two enforcement points, same library:

  • Gateway: coarse edge policies that need no business context: per IP, per API key, per plan. Stops abuse before it costs any service anything.
  • Service: policies that need business context the gateway does not have, such as “5 payment initiations per user” where the user comes from the authenticated session.
Component Responsibility
RateLimiter library Builds keys, resolves policies, calls Redis, falls back locally, sets headers. Used by the gateway and by services.
Redis Cluster Holds bucket state. The Lua script makes refill-and-consume atomic.
Policy store Versioned policy definitions; changes are pushed to every instance (and polled as a fallback).
Local buckets Per-instance token buckets used only when Redis is unreachable, sized as a fair share of the global limit.

Response contract

Every response that passed through a limiter carries the IETF RateLimit headers:

HTTP/1.1 200 OK
RateLimit-Policy: "free";q=100;w=60
RateLimit: "free";r=73;t=12

A rejection:

HTTP/1.1 429 Too Many Requests
Retry-After: 3
RateLimit: "free";r=0;t=3
Content-Type: application/problem+json

{ "type": "/errors/rate-limited", "status": 429, "code": "RATE_LIMITED",
  "policy": "free", "retryAfterSeconds": 3 }

Retry-After comes from the script: the exact time until enough tokens exist for this request, rounded up to whole seconds.

Policy definition

policies:
  - id: api-free
    match: { plan: free }
    key: [apiKey]
    algorithm: token-bucket
    capacity: 20             # burst
    refillPerMinute: 100     # sustained rate
    onStoreFailure: local
  - id: login
    match: { route: "POST /auth/login" }
    key: [ip, username]
    algorithm: sliding-log
    limit: 10
    window: 60s
    onStoreFailure: closed

Deployment

FIG 02ArchitectureDeployment

Primaries are spread across zones with replicas in the other zone, so losing a zone promotes replicas rather than losing all limiter state.

4. Internal design and workflows

Algorithms compared

Algorithm State per key Advantages Trade-offs
Fixed window counter 1 counter Simplest, one INCR Up to 2× the limit across a window boundary
Sliding-window log One timestamp per request Exact rolling window Memory grows with the limit; ZSET work per request
Sliding-window counter 2 counters Close to rolling, cheap Approximate at boundaries (assumes even spread)
Token bucket Tokens + last refill time Controlled bursts plus a sustained rate; constant memory Refill and consume must be atomic
Leaky bucket (queue) Queue position Smooth output Adds delay; suits traffic shaping more than API admission

Choosing the algorithm per policy

  • Token bucket for API quotas: it matches how APIs are actually used (bursts, then quiet) and expresses “burst 20, sustained 100/min” directly.
  • Sliding-window log for login and other security limits: “10 attempts in any 60 seconds” must hold exactly, and the limits are small, so the memory cost (10 timestamps) is trivial.

Class diagram

FIG 03Class modelClass diagram
public interface RateLimitStore {
    /** Atomically consumes `cost` from every bucket, or from none if any would be rejected. */
    List<RateLimitDecision> acquire(List<RateLimitKey> keys, List<RateLimitPolicy> policies, int cost);
}

Key design

rl:{t:acme}:api-free:k:ak_9f21…        token bucket for an API key, tenant acme
rl:{t:acme}:tenant-ceiling              tenant-wide bucket
rl:{ip:203.0.113.7}:login:u:manoj        sliding log for one IP + username
  • The hash tag in braces decides the Redis Cluster slot. A request’s key limit and its tenant ceiling share {t:acme}, so both live on one shard and one script can check them together atomically.
  • Secrets never appear in keys: API keys are hashed (first 16 hex characters of SHA-256).
  • Every key has a TTL equal to the time a bucket takes to refill completely, so idle keys expire on their own and memory stays bounded.

Preventing bypass:

  • IP-based keys use the client IP from the trusted proxy chain only (the gateway strips client-supplied X-Forwarded-For and sets its own). IPv6 addresses are limited per /64, because one host can rotate through a whole /64.
  • Login limits key on IP and username, plus a separate per-username limit, so rotating IPs does not bypass the per-account limit and one IP cannot lock out many accounts unnoticed.
  • Authenticated limits key on the account, not on anything the client controls.

The atomic token bucket (Redis Lua)

-- KEYS[1..n]  bucket keys (same hash slot)
-- ARGV[1]     cost
-- ARGV[2..]   capacity_i, refill_per_ms_i for each key, in order
local t = redis.call('TIME')                         -- Redis clock: no client clock skew
local now = t[1] * 1000 + math.floor(t[2] / 1000)
local cost = tonumber(ARGV[1])
local state, ok, wait = {}, true, 0

for i, key in ipairs(KEYS) do
  local cap  = tonumber(ARGV[2 * i])
  local rate = tonumber(ARGV[2 * i + 1])
  local v = redis.call('HMGET', key, 'tk', 'ts')
  local tokens = tonumber(v[1]) or cap
  local ts = tonumber(v[2]) or now
  if now > ts then tokens = math.min(cap, tokens + (now - ts) * rate) end
  if tokens < cost then
    ok = false
    wait = math.max(wait, math.ceil((cost - tokens) / rate))
  end
  state[i] = { tokens, cap, rate }
end

local out = {}
for i, key in ipairs(KEYS) do
  local tokens, cap, rate = state[i][1], state[i][2], state[i][3]
  if ok then tokens = tokens - cost end              -- all or nothing
  redis.call('HSET', key, 'tk', tokens, 'ts', now)
  redis.call('PEXPIRE', key, math.ceil(cap / rate))  -- idle buckets disappear when full again
  out[i] = math.floor(tokens)
end
table.insert(out, 1, ok and 1 or 0)
table.insert(out, 2, wait)
return out                                           -- {allowed, retry_ms, remaining_1, ...}

Why this is correct under concurrency: Redis runs a script to completion before running anything else on that shard, so refill, check and consume happen as one step. Two requests racing for the last token are serialised; exactly one sees tokens >= cost.

Why TIME inside the script: if each instance passed its own clock, a server 2 seconds fast would grant extra tokens. Using the shard’s clock gives every caller the same time.

Sliding-window log (login)

-- KEYS[1] zset of attempt timestamps; ARGV: limit, window_ms, request_id
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - tonumber(ARGV[2]))
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[1]) then
  local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
  return {0, tonumber(oldest[2]) + tonumber(ARGV[2]) - now}
end
redis.call('ZADD', KEYS[1], now, ARGV[3])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return {1, 0}

Decision sequence

FIG 04SequenceDecision sequence

Token bucket, worked

Capacity 20, refill 100 per minute (1 token every 0.6 s):

Time Event Tokens before Decision Tokens after
0.0 s 20 requests at once 20 All allowed 0
0.1 s 1 request 0.17 Rejected, Retry-After 1 s (0.5 s rounded up) 0.17
1.2 s 1 request 2.0 Allowed 1.0
60 s quiet minute, then 1 request 20 (capped) Allowed 19

Dynamic policies

Policies live in a versioned repository (or table). On change, a message on a config topic tells every instance to reload. Each instance also polls every 30 seconds in case it missed the message. A policy change takes effect on the next request, and existing bucket state carries over: lowering capacity simply clamps tokens to the new cap on the next refill.

Data ownership

The limiter owns only ephemeral counters. Losing all of Redis loses at most one refill period of state, so Redis runs without persistence for this workload (or with relaxed appendfsync everysec), which keeps latency low.

5. Failure modes and consistency

Scenario What would go wrong Design response
Two requests race for the last token Both admitted Lua script serialises them on the shard; exactly one wins.
Redis slow (p99 spikes) Every API request slows down Hard client timeout of 5 ms; on timeout, apply the policy’s failure mode. A circuit breaker opens after repeated timeouts so the limiter stops waiting.
Redis shard down No shared state for its keys onStoreFailure: local uses an in-memory bucket sized at limit / instance_count (instance count from service discovery). closed returns 503 for that policy. open admits.
Failover promotes a replica that missed recent writes Some buckets refill early Accepted: at most a few hundred milliseconds of admissions are lost. Documented as a known over-admission window.
Hot key (one tenant at 10,000 req/s) One shard saturated Tenant ceilings use local pre-allocation: each instance leases a batch of tokens (say 50) from Redis and spends them locally, returning to Redis only when the lease runs out. Accuracy drops by at most one batch per instance, which is stated in the policy.
Unbounded key growth (random API keys, scanned IPs) Memory exhaustion TTL on every key; unauthenticated traffic is limited by IP prefix, not by invented identifiers; Redis maxmemory-policy volatile-ttl as the last line.
Clock skew between instances Unequal refill Script uses Redis TIME; instance clocks are never used for bucket maths.
Policy update reaches some instances late Inconsistent limits for up to 30 s Acceptable for quotas; for security policies, the change is pushed and confirmed before it is announced.
Limiter bug rejects everything Self-inflicted outage Every policy can be switched to shadow mode (decide and log, never reject), which is how new policies are rolled out.

Fail open or fail closed?

Endpoint type Choice Reason
General API quotas Local fallback (approximately open) Availability matters more than a few minutes of imperfect quotas
Login, OTP, password reset Closed An attacker would wait for a Redis outage to brute-force accounts
Payment initiation Closed Abuse costs real money and chargebacks
Health checks, internal service-to-service Not limited The limiter must not take down its own monitoring

Concurrency and idempotency

Rate limiting consumes a token per attempt, including retries. Retried requests carrying the same Idempotency-Key are answered from the idempotency store before the limiter runs, so a client’s safe retry does not spend quota twice.

6. Scaling evolution

Stage Trigger Design Why
1. Single instance Launch In-memory token bucket (for example Bucket4j) per instance No network, no dependency
2. Multiple instances Second instance deployed Shared Redis with the Lua script; local buckets as fallback Per-instance counters multiply the real limit by the instance count
3. High volume Redis CPU over 50% or p99 over 1 ms Redis Cluster, hash tags per tenant, EVALSHA, connection pooling, pipelining several checks Spread load; keep one round trip per request
4. Latency-critical or hot keys Single keys over a few thousand req/s Local token leasing in batches, with the accuracy loss written into the policy Trade a bounded amount of accuracy for removing the network hop
5. Multi-region Users served from several regions Choose per policy: regional quotas (each region its own budget, simplest) or a global budget split into regional allocations rebalanced every few seconds Cross-region round trips per request would break the latency budget

7. Operations and validation

Metrics and SLOs

Signal SLO or alert
Limiter decision latency p99 under 2 ms; alert at 5 ms
Redis errors and timeouts Alert above 0.1%
Fallback mode active Alert immediately, with the policy and instance
Rejections per policy Dashboard; alert on a sudden 10× change (attack or misconfiguration)
Over-admission in tests Must be zero with Redis healthy
Keys in Redis, memory Alert at 70% of maxmemory

Test plan (to build)

Test Proves
1,000 concurrent requests from 20 threads on 2 JVMs against limit 100 (Testcontainers Redis) Exactly 100 allowed, none over
Refill: consume all, wait exactly one interval Exactly one more token
Retry-After versus actual time until success Never early; at most one second late
Two-key script: tenant ceiling rejects Neither bucket is decremented
Redis paused (DEBUG SLEEP) Timeout at 5 ms, then local, open or closed per policy
Fake X-Forwarded-For from a client Ignored; trusted IP used

Benchmark (to measure)

Measure: script calls per second per shard, decision latency p50, p95 and p99 at 25%, 50% and 100% of estimated peak, and accuracy (allowed ÷ limit) under 200 concurrent clients sharing one key. Run once with Redis healthy and once with one shard killed. Results will be added only after the runs.

8. Decision records

DR-1: Redis-backed token bucket rather than local counters

  • Context: 50 instances; quotas are per customer, not per instance.
  • Decision: shared state in Redis, refill and consume in one Lua script.
  • Rejected: local counters (a limit of 100 becomes up to 5,000 across 50 instances); INCR with a fixed window (allows 2× bursts at window edges and cannot express burst plus sustained rate); a database (too slow and too much write load for every request).

DR-2: Redis TIME, not the caller’s clock

  • Decision: every bucket calculation uses the shard’s clock.
  • Why: removes clock skew as a source of over-admission at no cost.

DR-3: Failure mode is part of each policy

  • Decision: onStoreFailure is mandatory in every policy definition.
  • Why: “what happens when Redis is down” has different right answers for login and for search. Forcing the choice at definition time prevents an implicit global default being wrong for security endpoints.

9. Limitations, and implemented versus proposed

Known limitations

  • Replica promotion after a Redis failure can over-admit for a short window.
  • Local leasing (stage 4) trades accuracy for latency by up to one batch per instance.
  • Rate limiting is not DDoS protection; volumetric attacks must be absorbed at the network edge before they reach the gateway.

Status

Item Status
Requirements, capacity, HLD, contracts, deployment Designed here
Class and sequence diagrams, key design, Lua scripts, worked example Designed here
Failure modes, fail-open versus fail-closed, idempotency, scaling, SLOs Designed here
Decision records, trade-offs, limitations Designed here
Java library (filter, policy resolver, Redis and local stores) To build
Concurrency, refill and failure-mode tests To build
Throughput, latency and accuracy benchmark To measure