100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Architecture

Designing a Rate Limiter

Walks through building a distributed rate limiter that protects APIs from abuse, comparing token bucket, sliding window, and fixed window algorithms and where state should live.

Designing Real SystemsIntermediate9 min readJul 9, 2026
Analogies

Designing a Rate Limiter

A rate limiter restricts how many requests a client (identified by user ID, API key, or IP) can make within a time window, protecting backend services from abuse, accidental retry storms, and noisy-neighbor overload. Designing one for a single process is straightforward — an in-memory counter suffices — but designing one for a distributed system with many stateless API servers behind a load balancer is a genuinely hard problem: the limiter's state (request counts) must be shared and updated atomically across all servers handling a given client's traffic, without adding significant latency to every request.

🏏

Cricket analogy: Like an umpire tracking how many overs a bowler has bowled to prevent exceeding the quota — easy for one umpire to count, but hard when multiple umpires across grounds must share one accurate count for the same bowler.

Where state lives: the core architectural decision

If each API server keeps its own local counter, a client can exceed the global limit by simply spreading requests across servers behind a round-robin load balancer. The standard fix is centralizing counters in a fast shared store — typically Redis — which every API server reads and writes on each request using an atomic increment-and-check operation (e.g., Lua script or Redis's built-in INCR with EXPIRE). This adds a network hop per request but keeps the check accurate and the API tier stateless. At very high scale, some systems accept eventual consistency and use local counters synchronized periodically, trading strict accuracy for lower latency.

🏏

Cricket analogy: Like assigning a single official scorer with a shared scorebook that every ground's umpire must check and update atomically, rather than each umpire keeping a private tally a bowler could exploit by switching ends.

Algorithm choices

Fixed window counters reset a counter every N seconds; they're simple but allow a burst of up to 2x the limit at window boundaries (e.g., a client sends the full quota at the end of one window and the full quota again at the start of the next). Sliding window log stores a timestamp per request and counts requests in the trailing window; it's accurate but memory-heavy at scale. Sliding window counter approximates the sliding log by weighting the previous and current fixed windows, giving good accuracy with fixed memory. Token bucket allows a burst up to bucket capacity while enforcing a steady average rate — tokens refill at a fixed rate and each request consumes one; this is the most widely used algorithm in production because it naturally accommodates bursty legitimate traffic. Leaky bucket instead smooths bursts into a constant output rate, useful when downstream systems need strictly uniform load.

🏏

Cricket analogy: Like a bowler's over-quota resetting every 6 balls (fixed window, allowing a burst of bouncers right at the reset), versus tracking every ball's exact timestamp in a trailing window (sliding log, precise but heavy), versus a token bucket letting a bowler save up a few extra yorkers for a big over while still averaging the agreed pace.

python
# Token bucket rate limiter backed by Redis, using an atomic Lua script
# Keeps: tokens (float), last_refill_timestamp

LUA_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])   -- tokens per second
local now = tonumber(ARGV[3])
local requested = 1

local data = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = tonumber(data[1]) or capacity
local last_ts = tonumber(data[2]) or now

local delta = math.max(0, now - last_ts)
tokens = math.min(capacity, tokens + delta * refill_rate)

if tokens >= requested then
    tokens = tokens - requested
    redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now)
    redis.call('EXPIRE', key, 3600)
    return 1   -- allowed
else
    redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now)
    return 0   -- rejected
end
"""
# Executed atomically via EVAL so concurrent servers never race on the same bucket

Stripe's public API rate limiter and GitHub's API both return standard headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset or Retry-After) so well-behaved clients can self-throttle instead of hammering the endpoint until rejected — a good rate limiter design communicates its state, not just enforces it.

A frequent design mistake is rate limiting purely at the application layer without a fast-fail edge/gateway layer. If every request — even ones that will be rejected — pays the cost of hitting an app server and a database, an attacker can still exhaust compute resources through sheer request volume. Rate limiting should happen as early as possible, ideally at the API gateway or load balancer, before expensive downstream work occurs.

  • Distributed rate limiting requires shared, atomically-updated state (commonly Redis) so limits are enforced consistently across all API servers.
  • Fixed window counters are simple but allow boundary bursts up to 2x the configured limit.
  • Token bucket is the most common production algorithm because it allows legitimate bursts while enforcing a steady average rate.
  • Sliding window counter approximates sliding log accuracy with fixed, bounded memory usage.
  • Rate limiting should happen as early as possible in the request path (gateway/edge) to avoid wasting downstream resources on requests that will be rejected.
  • Returning rate-limit headers lets well-behaved clients self-throttle instead of retrying blindly.

Practice what you learned

Was this page helpful?

Topics covered

#Architecture#SystemDesignStudyNotes#SoftwareEngineering#DesigningARateLimiter#Designing#Rate#Limiter#Where#StudyNotes#SkillVeris