Rate Limiting

Algorithms, distributed implementations, and production patterns.

0/0 checks

Why Rate Limiting

Goal Example
DDoS protection Block 10k req/s from single IP
Cost control Prevent runaway API billing
Fairness Stop one tenant from starving others
SLA enforcement Guarantee 99th percentile latency

Algorithms

Every rate-limiting algorithm answers the same question — "has this client used up its budget?" — but they disagree on how much memory that costs and how a burst is treated. The first three count requests against a window in time; the last two model a bucket instead of a counter. Fixed Window, Sliding Window, Token Bucket, and Leaky Bucket are the four shapes you'll actually reach for in production — sliding window log and sliding window counter are the accuracy/memory tradeoff inside "sliding window" itself.

Divide time into fixed windows (e.g., 1-minute buckets). Count requests per window. Reject once the count exceeds the limit.

Burst problem: a client can send 2x the limit across a window boundary, because each window's counter resets independently of the other — neither window alone looks like a violation.
graph LR
    subgraph W1["Window 1 — 00:00 to 01:00 — 100 requests, limit not exceeded"]
        R1["100 requests, spread across the window"]:::window
    end
    subgraph W2["Window 2 — 01:00 to 02:00 — 100 requests, limit not exceeded"]
        R2["100 requests, spread across the window"]:::window
    end
    B["200 requests inside a 2-second span straddling 01:00 — each window resets independently, so neither one alone looks like a violation"]:::burst
    R1 -->|"last 100 requests of window 1, sent right before 01:00"| B
    R2 -->|"first 100 requests of window 2, sent right after 01:00"| B
    classDef window fill:#3498db,stroke:#2471a3,color:#fff;
    classDef burst fill:#e74c3c,stroke:#c0392b,color:#fff;
Implementation is just a counter with a TTL:
key = "ratelimit:{user}:{minute}"
INCR key
EXPIRE key 60
Simple but flawed for burst-sensitive APIs.
Two variants trade memory for accuracy differently.

Sliding Window Log stores the timestamp of every request. On each request: (1) remove timestamps older than now - window, (2) count what's left, (3) reject if the count is at the limit, else record now. Accurate — no boundary burst is possible, since the window itself slides continuously instead of resetting. Memory heavy — it stores every timestamp per user; at 1000 req/min × 1M users that's 1B entries live in memory at once.

Sliding Window Counter approximates the same sliding window using two fixed-window counters and a weighted interpolation instead of a full timestamp log:
prev_window_count = 80
curr_window_count = 30
elapsed_in_current = 0.4  # 40% into current window

estimated = curr_window_count + prev_window_count * (1 - elapsed_in_current) = 30 + 80 * 0.6 = 78

Good enough: error rate stays under 0.1% against real traffic distributions, for O(1) memory per user instead of O(n).
A bucket holds up to capacity tokens. Tokens refill at a fixed rate (tokens/sec). Each request consumes 1 token; reject if the bucket is empty.

Burst-friendly: a client that hasn't sent anything in a while can spend its whole accumulated bucket in one burst — up to capacity requests all at once — then has to wait for tokens to trickle back in at rate.

AWS API Gateway uses token bucket for throttling — burst is the bucket size, rate is the refill rate.
Requests enter a queue and drain out at a fixed rate, regardless of how bursty the input rate is.

Smooths bursty traffic into a uniform output rate. Queue full → drop (or reject) the request. This is what nginx's limit_req implements by default.

Vs. token bucket: token bucket lets a burst straight through, up to capacity, as fast as the client can send it. Leaky bucket never lets the output rate exceed the configured rate at all — it queues the burst and drains it evenly instead of passing it through.


Algorithm Comparison

graph TD
    classDef risky fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef caution fill:#f39c12,stroke:#ba6018,color:#fff
    classDef safe fill:#27ae60,stroke:#1e8449,color:#fff
    classDef entry fill:#34495e,stroke:#212f3c,color:#fff

    T["Burst traffic arrives<br/>(same client, same instant)"]:::entry

    subgraph WINDOW["Window-based counters"]
        FW["Fixed Window<br/>O(1) memory"]
        SWL["Sliding Window Log<br/>O(n) memory — one entry per request"]
        SWC["Sliding Window Counter<br/>O(1) memory — two counters + interpolation"]
    end

    subgraph BUCKET["Bucket-based shaping"]
        TB["Token Bucket<br/>O(1) memory"]
        LB["Leaky Bucket<br/>O(queue) memory"]
    end

    T --> FW & SWL & SWC & TB & LB

    FW --> FW1["Boundary burst: up to 2x the limit<br/>can pass around the window edge"]:::risky
    SWL --> SWL1["Exact block at the true limit —<br/>but memory cost scales with request volume"]:::caution
    SWC --> SWC1["~99.9% accurate block —<br/>small interpolation error, constant memory"]:::safe
    TB --> TB1["Lets the burst through up to capacity,<br/>then throttles to the refill rate"]:::caution
    LB --> LB1["Queues the burst, drains it at a<br/>fixed rate — output is always smooth"]:::safe

Token bucket and leaky bucket both "handle" bursts, but the outcome for the client is opposite. What's the key difference in what actually happens to a burst under each?


Redis Implementations

Sliding Window Counter (Lua)

Atomic read-update-expiry in a single script — no race conditions, because the entire script runs as one indivisible operation on Redis's single-threaded execution.

-- KEYS[1] = current window key, KEYS[2] = prev window key
-- ARGV[1] = limit, ARGV[2] = elapsed_fraction (0.0–1.0), ARGV[3] = window_seconds

local curr = tonumber(redis.call('GET', KEYS[1])) or 0
local prev = tonumber(redis.call('GET', KEYS[2])) or 0
local limit = tonumber(ARGV[1])
local weight = 1 - tonumber(ARGV[2])
local estimated = curr + prev * weight

if estimated >= limit then
    return 0  -- rejected
end

redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]) * 2)
return 1  -- allowed
window = int(time.time() // 60)
curr_key = f"ratelimit:{user_id}:{window}"
prev_key = f"ratelimit:{user_id}:{window - 1}"
elapsed  = (time.time() % 60) / 60  # fraction into current window

allowed = redis.evalsha(sha, 2, curr_key, prev_key, LIMIT, elapsed, 60)
sequenceDiagram
    participant APP as Application
    participant REDIS as Redis (single-threaded execution)

    APP->>REDIS: EVALSHA sliding_window_script<br/>KEYS: curr_key, prev_key — ARGV: limit, elapsed, window
    Note over REDIS: the whole script runs atomically —<br/>no other client's script can interleave mid-way
    REDIS->>REDIS: GET curr, GET prev
    REDIS->>REDIS: estimated = curr + prev * (1 - elapsed)
    alt estimated >= limit
        REDIS-->>APP: return 0 (rejected)
    else estimated < limit
        REDIS->>REDIS: INCR curr, EXPIRE curr
        REDIS-->>APP: return 1 (allowed)
    end

Token Bucket (MULTI/EXEC)

def is_allowed(redis, user_id, capacity, rate):
    key = f"tokenbucket:{user_id}"
    now = time.time()

    with redis.pipeline() as pipe:
        while True:
            try:
                pipe.watch(key)
                data = pipe.hgetall(key)
                tokens   = float(data.get(b'tokens', capacity))
                last_ref = float(data.get(b'last',   now))

                # refill
                elapsed = now - last_ref
                tokens  = min(capacity, tokens + elapsed * rate)

                if tokens < 1:
                    pipe.unwatch()
                    return False

                pipe.multi()
                pipe.hset(key, mapping={'tokens': tokens - 1, 'last': now})
                pipe.expire(key, int(capacity / rate) + 10)
                pipe.execute()
                return True
            except redis.WatchError:
                continue  # retry on concurrent modification

WATCH/MULTI/EXEC is optimistic concurrency, not a lock: any client is free to read and compute against the key at the same time, and EXEC only fails if some other client's write actually landed on the watched key first.

sequenceDiagram
    participant APP as Application
    participant REDIS as Redis

    loop until EXEC succeeds
        APP->>REDIS: WATCH tokenbucket:{user}
        APP->>REDIS: HGETALL tokenbucket:{user}
        REDIS-->>APP: tokens, last_refill_time
        APP->>APP: refill: tokens = min(capacity, tokens + elapsed * rate)
        alt tokens < 1
            APP->>REDIS: UNWATCH
            APP-->>APP: return False (rejected)
        else tokens >= 1
            APP->>REDIS: MULTI / HSET tokens-1, last=now / EXEC
            alt another client wrote to the key first
                REDIS-->>APP: EXEC aborts (WatchError)
                Note over APP: loop retries — re-reads fresh state, recomputes from scratch
            else no interleaving write happened
                REDIS-->>APP: EXEC succeeds
                APP-->>APP: return True (allowed)
            end
        end
    end

The Python implementation above retries in a loop on redis.WatchError instead of just catching it and returning False. Why is retrying the correct response, not rejecting the request?


Distributed Rate Limiting

Every API server needs to agree on the same count for the same client — otherwise the limit isn't actually a limit.

graph TD
    classDef bad fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef good fill:#27ae60,stroke:#1e8449,color:#fff
    classDef client fill:#34495e,stroke:#212f3c,color:#fff
    classDef server fill:#3498db,stroke:#2471a3,color:#fff

    CLIENT["Client traffic"]:::client

    subgraph PROBLEM["Without coordination — each server counts alone"]
        S1P["API Server 1<br/>local counter: 100/100"]:::server
        S2P["API Server 2<br/>local counter: 100/100"]:::server
        S3P["API Server 3<br/>local counter: 100/100"]:::server
        RESULT_BAD["Up to 300 requests pass —<br/>3x the intended limit"]:::bad
        S1P & S2P & S3P --> RESULT_BAD
    end

    subgraph SOLUTION["With a central Redis counter"]
        S1S["API Server 1"]:::server
        S2S["API Server 2"]:::server
        S3S["API Server 3"]:::server
        REDIS_C["Redis Cluster<br/>one shared counter per rate-limit key"]:::good
        S1S & S2S & S3S -->|"every check hits<br/>the same counter"| REDIS_C
        REDIS_C --> RESULT_GOOD["Exactly 100 requests pass —<br/>limit enforced globally"]:::good
    end

    CLIENT -.-> S1P
    CLIENT -.-> S1S

Distributed Rate-Limit Check, Step by Step

1. Request arrives at any API server. The load balancer sends it to Server 1, 2, or 3 with no stickiness — none of them hold rate-limit state locally, so it genuinely doesn't matter which one gets it.
2. The server computes the rate-limit key and its Redis Cluster slot. hash_slot = CRC16("ratelimit:{user_id}") % 16384 — the curly braces around the fixed part of the key force Redis Cluster to hash only that portion, so every key belonging to this user lands on the same slot.
3. The server calls an atomic Lua script on that one shard. EVALSHA passes the key(s) as KEYS, so the entire read-check-increment happens inside a single round trip and a single atomic execution on Redis — not split across separate GET/SET calls from the app.
4. Redis executes the check and the increment in the same atomic step. No other client's script — issued from any other API server — can interleave partway through this one.
5. The shard returns allow or reject, and that's final. Every other server checking this same user's key hits the exact same shard and the exact same counter, which is what makes the limit global instead of per-server.

What Breaks Without Atomicity

sequenceDiagram
    participant S1 as API Server 1
    participant S2 as API Server 2
    participant R as Redis

    Note over S1,R: Non-atomic GET-then-SET — the race
    S1->>R: GET tokens
    R-->>S1: 1
    S2->>R: GET tokens
    R-->>S2: 1
    Note over S1,S2: both saw 1 token available — both decide to allow
    S1->>R: SET tokens 0
    S2->>R: SET tokens 0
    Note over R: 2 requests admitted from a bucket<br/>that only ever had 1 token

Fix: use Lua scripts (atomic, as in the stepper above) or Redis's INCR + TTL pattern — anything that turns "read, decide, write" into one operation Redis executes without interruption, instead of two separate round trips a second server can slip in between.

Redis Cluster Sharding

Route each rate limit key to a consistent shard:

hash_slot = CRC16("ratelimit:{user_id}") % 16384

{} in the key forces Redis Cluster to hash only the bracketed part — all keys for a user land on the same slot, enabling Lua scripts across those keys.

In the GET-then-SET race above, both servers read 1 token and both admitted a request — 2 requests passed from a bucket that only had 1 token. What specifically makes this possible, and what actually closes the gap?


Rate Limit Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1719640800
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1719640800
Header Value
X-RateLimit-Limit Max requests in window
X-RateLimit-Remaining Requests left in current window
X-RateLimit-Reset Unix timestamp when window resets
Retry-After Seconds until client may retry (RFC 7231)

nginx Rate Limiting

http {
    # define shared memory zone: 10MB stores ~160k IPs
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    server {
        location /api/ {
            # burst=20: allow queue of 20 extra requests
            # nodelay: serve burst immediately (no artificial delay)
            limit_req zone=api burst=20 nodelay;
            limit_req_status 429;
        }
    }
}
nginx delays burst requests, releasing them at the steady configured rate (10r/s above) instead of all at once — effectively leaky bucket behavior. Higher latency for the queued requests, but the outbound rate to the upstream never spikes above the configured limit.
Burst requests — up to the burst=20 allowance — pass through immediately with no artificial delay, effectively token bucket behavior. Only requests beyond the combined rate + burst allowance get a 429; nothing in the allowed burst is held back to smooth the rate.

In limit_req zone=api burst=20 nodelay;, what does removing nodelay actually change about how those 20 burst requests are served?


AWS API Gateway Throttling

graph TD
    classDef acct fill:#8e44ad,stroke:#6c3483,color:#fff
    classDef stage fill:#3498db,stroke:#2471a3,color:#fff
    classDef method fill:#27ae60,stroke:#1e8449,color:#fff

    subgraph ACCT["AWS account — hard ceiling"]
        A["10,000 req/s<br/>shared across every API in the account"]:::acct
        subgraph STAGE["Deployment stage"]
            ST["1,000 req/s<br/>shared across every route in this stage"]:::stage
            subgraph METHOD["Route / method override"]
                M["100 req/s<br/>this specific route only"]:::method
            end
        end
    end

    A -.->|"stage limit can only be<br/>tighter than the account limit"| ST
    ST -.->|"method limit can only be<br/>tighter than the stage limit"| M

Usage Plans — attach to API keys for per-customer limits:

Usage Plan "free-tier":
  rate:  10 req/s
  burst: 50          # token bucket capacity
  quota: 10,000/day
  • rate = token refill rate
  • burst = bucket capacity (short spike allowed)
  • quota = daily hard limit

Exceeds rate/burst → 429 Too Many Requests Exceeds quota → 429 Limit Exceeded

A usage plan's rate/burst and its daily quota are both enforced with a 429 status. What's the actual difference between the two, and how would you tell them apart?


Rate Limiting Strategies

Strategy Key Use Case
Per-user ratelimit:user:{user_id} Authenticated API
Per-IP ratelimit:ip:{ip} Public endpoints, unauthenticated
Per-API-key ratelimit:key:{api_key} B2B / partner APIs
Per-endpoint ratelimit:{user}:{route} Expensive endpoints (search, export)
Global ratelimit:global System-wide DDoS protection

Combine: per-IP at edge (nginx/CDN) + per-user in app layer + per-endpoint for expensive routes.

One tenant's export calls are hammering the search index and slowing down every other tenant, but their normal API traffic is fine. Which key from the table above stops just that one route, without also capping the tenant's other requests?


Graceful Degradation

Reject vs Queue

Reject (fail fast) Queue
Latency Low Higher
Client UX Gets 429 immediately May wait and succeed
Server load Bounded Can grow unbounded
Use case Stateless API Background jobs, webhooks

Priority Queues for Premium Users

graph LR
    classDef premium fill:#f39c12,stroke:#ba6018,color:#fff
    classDef standard fill:#3498db,stroke:#2471a3,color:#fff
    classDef worker fill:#27ae60,stroke:#1e8449,color:#fff

    IN["Incoming requests"] -->|"plan == premium"| PQ["Premium queue<br/>capacity: 500"]:::premium
    IN -->|"plan == standard"| SQ["Standard queue<br/>capacity: 100"]:::standard
    PQ -->|"drained first"| W["Workers"]:::worker
    SQ -->|"drained only once the<br/>premium queue is empty"| W
def enqueue(request):
    plan = get_user_plan(request.user_id)
    queue = "queue:premium" if plan == "premium" else "queue:standard"

    if redis.llen(queue) >= QUEUE_LIMITS[plan]:
        return 429  # queue full, reject

    redis.rpush(queue, serialize(request))
    return 202  # accepted

Workers drain premium queue first; fall through to standard when idle.

The premium queue has 3 requests waiting. The standard queue has 50. A worker just finished a job and is looking for its next one. Which queue does it pull from?

Shedding Strategy

System load Behavior
< 70% Allow all traffic
70–90% Drop standard tier, allow premium
> 90% Drop all non-critical, return 503

Quick Reference

Algorithm Memory Burst Accuracy Best For
Fixed window O(1) Yes (boundary) Low Simple counters
Sliding window log O(n) No Exact Audit logs
Sliding window counter O(1) Partial ~99.9% General API limiting
Token bucket O(1) Yes (controlled) High API gateways
Leaky bucket O(queue) No (smoothed) High Traffic shaping