Backend / Architecture & design / 17_rate_limiting_algorithms.md

Rate-Limiting Algorithms

Updated 7 interview angles 6 min read source
On this page12
  1. Fixed window
  2. Sliding window log
  3. Sliding window counter
  4. Token bucket
  5. Leaky bucket (request leak)
  6. GCRA (Generic Cell Rate Algorithm)
  7. Where to enforce
  8. Distributed rate limiting
  9. Response shape (HTTP)
  10. What to limit on
  11. What to do when rate-limited
  12. Interview angle

Rate-Limiting Algorithms

“Limit requests per user/IP/endpoint” sounds simple. The algorithm matters — fixed window has obvious flaws, sliding window costs more but is fairer, token bucket handles bursts cleanly. Senior interview territory.

Fixed window

The simplest. Count requests per N-second window; reset at boundaries.

python
def allowed_fixed(user, limit=100, window=60):
    bucket = f"rate:{user}:{int(time.time() // window)}"
    count = r.incr(bucket)
    if count == 1:
        r.expire(bucket, window)
    return count <= limit

Pros: trivially cheap, easy to reason about. Cons: boundary burst — a user can do limit in the last second of one window and limit in the first second of the next. Effective rate at the boundary is 2× the configured limit.

Acceptable for forgiving limits (1000/min); broken for tight ones (10/min).

Sliding window log

Store every request timestamp; on each request, count timestamps within the last N seconds.

python
def allowed_sliding_log(user, limit=100, window=60):
    key = f"rate:{user}"
    now = time.time()
    cutoff = now - window
    p = r.pipeline()
    p.zremrangebyscore(key, 0, cutoff)        # drop old entries
    p.zadd(key, {str(now): now})              # add this request
    p.zcard(key)                              # current count
    p.expire(key, window)
    _, _, count, _ = p.execute()
    return count <= limit

Pros: precisely correct; no boundary burst. Cons: memory grows with active request count per user; storage cost matters at scale.

Sliding window counter

Approximation: count in the current window + a weighted fraction of the previous window’s count.

python
def allowed_sliding_counter(user, limit=100, window=60):
    now = time.time()
    cur_window = int(now // window)
    prev_window = cur_window - 1
    # 0.0 to 1.0
    pos_in_window = (now - cur_window * window) / window

    cur_key = f"rate:{user}:{cur_window}"
    prev_key = f"rate:{user}:{prev_window}"

    cur_count = int(r.get(cur_key) or 0)
    prev_count = int(r.get(prev_key) or 0)

    weighted = cur_count + prev_count * (1 - pos_in_window)
    if weighted >= limit:
        return False

    p = r.pipeline()
    p.incr(cur_key)
    p.expire(cur_key, window * 2)
    p.execute()
    return True

Pros: O(1) memory per user; close to true sliding window. Cons: approximation; slightly over- or under-permissive depending on traffic shape. Good enough for most production needs.

Token bucket

Tokens replenish at a fixed rate; each request consumes one. Allows bursts up to the bucket capacity.

python
def allowed_token_bucket(user, capacity=10, refill_rate=1):  # 1 token per second; burst up to 10
    key = f"rate:{user}"
    now = time.time()
    lua = """
    local capacity = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    local tokens = tonumber(redis.call('hget', KEYS[1], 'tokens') or capacity)
    local last = tonumber(redis.call('hget', KEYS[1], 'last') or now)
    local elapsed = math.max(0, now - last)
    tokens = math.min(capacity, tokens + elapsed * refill_rate)
    if tokens < 1 then
        redis.call('hset', KEYS[1], 'last', now)
        return 0
    end
    tokens = tokens - 1
    redis.call('hset', KEYS[1], 'tokens', tokens)
    redis.call('hset', KEYS[1], 'last', now)
    redis.call('expire', KEYS[1], 3600)
    return 1
    """
    return bool(r.eval(lua, 1, key, capacity, refill_rate, now))

Pros: elegant bursting; intuitive (“you have X tokens, refill at Y/sec”). Used by AWS API rate limits, GitHub, Stripe. Cons: Lua script for atomicity; slightly more state per key.

Leaky bucket (request leak)

A queue with a fixed leak rate. Excess requests overflow.

Conceptually like token bucket inverted: instead of “tokens refill and you consume”, “queue fills and you leak at fixed rate.” Smoothing-focused.

In practice rarely implemented for HTTP rate limiting; nginx’s limit_req is a leaky bucket variant. Token bucket is the more common API rate limiting metaphor.

GCRA (Generic Cell Rate Algorithm)

A precise variant of leaky bucket from telephony. Stores just one timestamp per key: “earliest time the next request would be allowed.” Single comparison + update per request.

python
def allowed_gcra(user, period=1.0, burst=10):
    # period = average gap between requests; burst = tolerance
    key = f"rate:{user}"
    now = time.time()
    lua = """
    local period = tonumber(ARGV[1])
    local burst = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    local tat = tonumber(redis.call('get', KEYS[1]) or 0)
    local new_tat = math.max(tat, now) + period
    local allow_at = new_tat - period * burst
    if now < allow_at then
        return 0
    end
    redis.call('set', KEYS[1], new_tat)
    redis.call('expire', KEYS[1], math.ceil(period * burst) + 1)
    return 1
    """
    return bool(r.eval(lua, 1, key, period, burst, now))

Used by GitHub, Twitter, Stripe internally. Most accurate + most memory-efficient.

Where to enforce

Layer Use case
CDN / WAF (CloudFront, Cloudflare) global DoS protection, very high RPS
API Gateway (AWS API GW, nginx) per-API-key / per-user / per-IP, app-agnostic
Application middleware business logic (per-endpoint, per-user-tier)

Enforce at the cheapest layer that has the context. Gateway sees IPs; gateway can rate-limit by IP. Per-user-tier (“free tier = 100 RPM, paid = 1000 RPM”) needs application context.

Distributed rate limiting

Most algorithms above work over Redis. The atomic operations (INCR, ZADD, Lua scripts) keep state consistent across multiple API servers.

Common gotchas:

  • Redis latency — every request hits Redis. At very high RPS, Redis becomes the bottleneck.
  • Hot keys — one popular user’s rate-limit counter takes all the traffic on one Redis shard. Use cluster mode + key hashing.
  • Local + distributed combo — local approximate counter (LRU cache) + periodic flush to Redis. Trades accuracy for throughput.

Response shape (HTTP)

text
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1709125800

Retry-After is the standard. Some APIs include RateLimit-* headers (informational).

For long-running clients, expose the rate-limit state proactively in response headers on EVERY response (not just 429s); they can self-throttle.

What to limit on

Common keys:

  • API key — per-customer tier enforcement.
  • User ID — per-user prevent abuse.
  • IP address — anonymous abuse / DoS protection.
  • Endpoint — protect expensive endpoints separately.
  • Compound(user_id, endpoint) for fine-grained.

Often layer multiple: per-IP (DoS), per-API-key (tier), per-endpoint (expense). Each with its own limit.

What to do when rate-limited

  • Return 429 immediately — fast feedback.
  • Cost shedding — sample the work; return partial results.
  • Queue + asynchronous reply — accept, return 202 with a Location for the result, process when capacity allows.
  • Tarpitting — slow the response intentionally to make abuse expensive. Last-resort, rarely needed.

Don’t silently drop. 429 with Retry-After is the right behavior.

Interview angle 7

  • “What’s the boundary burst problem in fixed-window rate limiting?” — a user can do limit in the last second of one window and limit in the first second of the next. Effective rate at the boundary is 2× the configured limit. Fixed windows have it; sliding window / token bucket / GCRA don’t.
  • “Token bucket vs leaky bucket?” — token bucket: tokens refill, each request consumes one, bursts up to capacity. Leaky bucket: requests queue, leak at fixed rate, excess overflow. Token bucket allows bursts; leaky bucket smooths. Both end up bounded; token bucket is the more common API metaphor.
  • “What’s GCRA?” — Generic Cell Rate Algorithm. Precise leaky-bucket variant from telecom. Stores one timestamp per key (TAT — theoretical arrival time). Single check + update per request. Most memory-efficient + accurate.
  • “How do you do rate limiting across multiple API servers?” — shared state in Redis via atomic ops (INCR, ZADD, Lua scripts). Each request hits Redis to check/update the counter. Gotchas: Redis latency at the hot path, hot keys for popular users.
  • “Where do you enforce rate limits?” — at the cheapest layer with the right context. CDN / WAF for global DoS; gateway for per-API-key / per-IP; application for per-user-tier business logic. Often combined.
  • “What HTTP status and headers do you return when rate-limited?” — 429 Too Many Requests, with Retry-After: <seconds>. Optionally X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset for proactive client-side throttling.
  • “You need per-user 10/sec with burst tolerance of 50. Which algorithm?” — token bucket: capacity=50, refill_rate=10/sec. Allows a 50-request burst, then sustained 10/sec. Sliding window log with 1-second window would also work but limit semantics get confusing for the burst case.