System Design
B3

API gateway and rate limiting

A gateway checks every request before a service sees it. The rate limit counts per client in Redis, with one atomic script, and refuses with 429.

Not startedSaved in this browser only.
  1. 1The gateway runs cheap checks first: authenticate, rate limit, quota, then route.
  2. 2Count in Redis with one atomic script per request, so every gateway copy shares one limit.
  3. 3Token bucket allows bursts; sliding windows are exact; a fixed window lets twice the limit through at a boundary.
  4. 4Refuse with 429 and Retry-After. Decide in advance whether to fail open or closed when Redis is down.
B3
    A

    What a gateway does

    one request, in order
    Clientapp, partnerAPI gatewaystateless; N copies behind a balancerEnd TLScertificatesAuthenticatekey, token401Rate limitper client429Quotaper plan429Routepath → service404TransformheadersServicesorders, usersRedisone script per checkCheap checks first:a refused requestnever reaches a service.

    The gateway authenticates, rate-limits and routes, so every service does not repeat that work. It is stateless; the counts live in Redis.

    B

    Where to limit

    each layer stops a different flood
    whereruns onstatus
    Edge or CDN, by IPEdge, WAFIP floods
    Gateway, by client, count in RedisRedisApproved
    Each service, in memory onlyServiceOne instance
    Client SDK throttles itselfClientCourtesy only
    Bounded pool or queue at the databaseServiceLast line
    No limit; scale out on loadServiceNot approved
    • Key the limit by the authenticated client, not by IP. Many users share one NAT address.
    • Limit per route too. A search costs more than a profile read.
    • A client SDK can be changed or skipped, so the server must enforce the limit.

    I limit by IP at the edge, by client in the gateway with Redis, and I keep a bounded pool in front of the database.

    C

    The answer: 429

    what the client sees
    refused requestpseudo code
    HTTP/1.1 429 Too Many Requests1
    Retry-After: 8                    // whole seconds, rounded up2
    
    client, on 429:
      wait Retry-After + a random jitter3
      THEN retry once; on a second 429, wait longer
    1. 1The status for a rate limit. Use 503 when the server, not this client, is the problem.
    2. 2Retry-After takes whole seconds. A wait of 7.7 s becomes 8. Rounding down would invite a second refusal.
    3. 3Jitter spreads the retries. Without it, every refused client returns in the same second.
    Tested source Go: refuse with 429
    Go: refuse with 429go
    if !dec.Allowed {
      secs := (dec.RetryAfter + time.Second - 1) / time.Second
      w.Header().Set("Retry-After", strconv.FormatInt(int64(secs), 10))
      http.Error(w, "too many requests", http.StatusTooManyRequests)
      return
    }
    • A refused request still costs one limiter call. A local tier makes that call free.
    • Return the remaining count on success, so a well-behaved client can slow down early.

    I refuse with 429 and Retry-After in seconds. The client waits that long plus jitter, so refused clients do not return together.

    D

    Five algorithms

    state, bursts, accuracy
    algorithmstate per clientstatus
    Fixed window1 counter2× at a boundary
    Sliding window log1 entry per requestSmall limits
    Sliding window counter2 countersClose to exact
    Token buckettokens, last timeApproved
    Leaky bucket (queue)time it emptiesSmooth output
    • Fixed window: one INCR, the cheapest. It forgets everything at each boundary.
    • Sliding log: exact, but memory grows with the limit.
    • Sliding counter: weights the last window by its overlap. The lab replay let 24 through against 20.
    • Token bucket: a burst up to the bucket size, then the average rate.
    • Leaky bucket: a constant output rate. Requests wait for a slot.

    My default is a token bucket: it allows short bursts and holds the average. I use a sliding window when the limit must be exact.

    E

    The window boundary

    why a fixed window leaks
    limit: 20 per 10 s windowwindow 1: count 20 ✓window 2: count 20 ✓20 at 9.9 s20 at 10.0 s0 s5 s10 s15 s20 s✕ Fixed window: 40 admitted within 100 ms, twice the limit✓ Sliding log: the last 10 s hold 20, so the next 20 are refused

    Tested in the lab: 20 requests at 9.9 s and 20 at 10.0 s. The fixed window admitted 40. The sliding log and the sliding counter admitted 20.

    A fixed window resets at its boundary, so a client can send the limit twice within 100 ms. A sliding window counts the last full window instead.

    F

    Try it: one burst, five limiters

    recorded from the Redis scripts

    Limit: 20 requests per 10 s, an average of 2 a second. One client sends 106 requests in 40 s. Dashed lines mark the 10 s windows. Click a bar to see its first request.

    arrivals per 500 ms: admitted below, refused above10sent on to the service per 500 ms2 a second0 s5 s10 s15 s20 s25 s30 s35 s40 s
    • admitted
    • refused, 429
    • reaches the service
    t = 10 s, first request in this bar
    > EVALSHA 952b713fee8d… rl:{burst}:fw:10000 10000 10000 20
    < {1, 0, …, 0}   allowed, retry after ms, remaining, delay ms
    < HTTP 200 OK
    algorithmadmittedbusiest 1 sbusiest 10 s (limit 20)longest wait
    Fixed window80 of 1062039none
    Sliding log58 of 1061120none
    Sliding counter62 of 1061124none
    Token bucket84 of 1062035none
    Leaky bucket84 of 1062209.5 s

    A token bucket promises the average rate plus one burst, not 20 in every 10 s. In any 10 s it can admit up to 20 + 10 × 2 = 40.

    The fixed window lets a double burst through at the boundary. The sliding log holds exactly 20 in any 10 s. The leaky bucket sends 2 a second and makes the rest wait.

    G

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    RedisINCR and PEXPIREAn atomic counter per window that deletes itself. A fixed window needs nothing more.Page views, unique IDs
    RedisServer-side scripts (EVAL, EVALSHA)Read the state, decide, write, as one step. No other command runs in between.Inventory holds, idempotency
    RedisSorted sets: ZADD, ZREMRANGEBYSCORE, ZCARDA log of request times. Drop the old ones by score, count the rest.Leaderboards, delay queues
    RedisHashes: HMGET, HSETToken bucket state: tokens and the time of the last refill, in one key.Sessions, small records
    RedisCluster hash tags: rl:{user:42}Every key of one client lives in one slot, so one script can read two windows.Any multi-key operation in a cluster
    RedisTIMEOne clock for every gateway. Gateway clocks drift apart.Leases, expiry checks
    RedisAsynchronous replicationLimit A failover can lose recent counts. A client may get a little extra for one window.
    ServiceIn-memory token bucket per clientThe local tier: about 3.5 million decisions a second in the lab, with no network call.Concurrency limits
    ServiceHTTP 429 and Retry-AfterA standard refusal that tells the client how long to wait.503 during maintenance
    API gatewayAuth, routing, limits in one stateless tierServices stay simple. One place to change a limit.API keys, versioning, canaries

    Redis gives me atomic counters with expiry, scripts that read and write in one step, and sorted sets for a sliding log. The gateway gives me one place to enforce it.

    H

    Counting windows

    pseudo code
    fixed, log, counterpseudo code
    fixed_window(client, now):            // ONE atomic script1
      key = client + start of this window
      count = INCR key; IF count = 1: expire key after window2
      IF count <= limit: RETURN allow
      RETURN refuse, retry after = window end - now3
    
    sliding_log(client, now):
      remove times <= now - window4 from the sorted set
      IF size < limit: add now; RETURN allow
      RETURN refuse, retry after = oldest + window - now
    
    sliding_counter(client, now):
      estimate = previous × (window - elapsed) / window5 + current
      IF estimate + 1 <= limit: INCR current; RETURN allow
      RETURN refuse
    1. 1Two gateways can no longer both read 19 and both write 20. In the lab, 200 callers at once got exactly 20 admits.
    2. 2Expiry only cleans up. The decision reads the time passed in, so a slow expiry never admits extra requests.
    3. 3Exact: one ms earlier is still refused, and at this time the request passes. Tested for every algorithm.
    4. 4The sorted set holds one entry per admitted request. Memory grows with the limit.
    5. 5Assumes the last window spread its requests evenly. A burst at its end breaks that assumption, so the count can run a little over.
    Tested source Redis script: fixed window · Redis script: sliding log · Redis script: sliding counter
    Redis script: fixed windowlua
    -- Fixed window: count requests in the current window; refuse once the count passes the limit.
    -- KEYS[1] the counter for this window: rl:{user:42}:fw:<window start, ms>
    -- ARGV[1] now, ms    ARGV[2] window, ms    ARGV[3] limit
    -- Returns {allowed, retry after ms, remaining, delay ms}.
    local now = tonumber(ARGV[1])
    local window = tonumber(ARGV[2])
    local limit = tonumber(ARGV[3])
    local count = redis.call('INCR', KEYS[1])
    if count == 1 then
      redis.call('PEXPIRE', KEYS[1], window)
    end
    if count <= limit then
      return {1, 0, limit - count, 0}
    end
    local window_end = now - now % window + window
    return {0, window_end - now, 0, 0}
    Redis script: sliding loglua
    -- Sliding window log: keep the time of every admitted request; admit while fewer than the limit
    -- fall inside the last window.
    -- KEYS[1] sorted set, member = request id, score = time: rl:{user:42}:log
    -- ARGV[1] now, ms    ARGV[2] window, ms    ARGV[3] limit    ARGV[4] request id
    -- Returns {allowed, retry after ms, remaining, delay ms}.
    local now = tonumber(ARGV[1])
    local window = tonumber(ARGV[2])
    local limit = tonumber(ARGV[3])
    redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
    local count = redis.call('ZCARD', KEYS[1])
    if count < limit then
      redis.call('ZADD', KEYS[1], now, ARGV[4])
      redis.call('PEXPIRE', KEYS[1], window)
      return {1, 0, limit - count - 1, 0}
    end
    local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
    return {0, tonumber(oldest[2]) + window - now, 0, 0}
    Redis script: sliding counterlua
    -- Sliding window counter: two fixed-window counters. The estimate weights the previous window by
    -- the part of it that still overlaps the sliding window:
    --   estimate = previous × (window − elapsed) / window + current
    -- Multiply through by window to stay in whole numbers.
    -- KEYS[1] current window counter    KEYS[2] previous window counter
    -- ARGV[1] now, ms    ARGV[2] window, ms    ARGV[3] limit
    -- Returns {allowed, retry after ms, remaining, delay ms}.
    local now = tonumber(ARGV[1])
    local window = tonumber(ARGV[2])
    local limit = tonumber(ARGV[3])
    local elapsed = now % window
    local curr = tonumber(redis.call('GET', KEYS[1]) or '0')
    local prev = tonumber(redis.call('GET', KEYS[2]) or '0')
    local used = prev * (window - elapsed) + curr * window
    if used + window <= limit * window then
      redis.call('INCR', KEYS[1])
      redis.call('PEXPIRE', KEYS[1], 2 * window)
      return {1, 0, math.floor((limit * window - used - window) / window), 0}
    end
    -- Refused. Find the first moment the estimate leaves room, if no other request arrives.
    -- In this window: previous × (window − e) + (current + 1) × window ≤ limit × window.
    if prev > 0 and curr + 1 <= limit then
      local e = window - math.floor((limit - curr - 1) * window / prev)
      if e < window then
        return {0, e - elapsed, 0, 0}
      end
    end
    -- In the next window the current count becomes the previous one.
    local e2 = 0
    if curr > 0 then
      e2 = math.max(0, window - math.floor((limit - 1) * window / curr))
    end
    return {0, window - elapsed + e2, 0, 0}

    Each limiter is one Redis script. It reads the count, decides and writes in one atomic step, so 200 gateways at once admit exactly the limit.

    I

    Buckets

    pseudo code
    token, leakypseudo code
    token_bucket(client, now):            // ONE atomic script
      tokens = min(burst2, tokens + (now - last) × rate1)
      IF tokens >= 1: tokens -= 1; RETURN allow
      RETURN refuse, retry after = (1 - tokens) / rate3
    
    leaky_bucket(client, now):
      slot = max(empty_at, now)             // the next free slot
      IF slot - now > (capacity - 1) × interval: RETURN refuse
      empty_at = slot + interval
      RETURN allow, wait until slot4
    1. 1Refill on read. No timer runs per client, so an idle client costs only one small key.
    2. 2The bucket size caps the burst. An idle client can send 20 at once, then 2 a second.
    3. 3Time until one whole token. The script counts thousandths of a token, so it stays in whole numbers.
    4. 4The gateway holds the request until its slot. Output is one request every 500 ms, whatever the input.
    Tested source Redis script: token bucket · Redis script: leaky bucket
    Redis script: token bucketlua
    -- Token bucket: a bucket holds up to `burst` tokens and refills at `rate` tokens a second. Each
    -- request takes one token. Tokens are counted in thousandths, so the script stays in whole numbers.
    -- KEYS[1] hash with fields tokens and ts: rl:{user:42}:tb
    -- ARGV[1] now, ms    ARGV[2] rate, tokens a second    ARGV[3] burst
    -- Returns {allowed, retry after ms, remaining, delay ms}.
    local now = tonumber(ARGV[1])
    local rate = tonumber(ARGV[2]) -- tokens a second = thousandths of a token per ms
    local cap = tonumber(ARGV[3]) * 1000
    local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
    local tokens = tonumber(state[1]) or cap
    local ts = tonumber(state[2]) or now
    if now > ts then
      tokens = math.min(cap, tokens + (now - ts) * rate)
      ts = now
    end
    local allowed, retry = 0, 0
    if tokens >= 1000 then
      tokens = tokens - 1000
      allowed = 1
    else
      retry = math.floor((1000 - tokens + rate - 1) / rate)
    end
    redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', ts)
    redis.call('PEXPIRE', KEYS[1], math.floor(cap / rate) + 1000)
    return {allowed, retry, math.floor(tokens / 1000), 0}
    Redis script: leaky bucketlua
    -- Leaky bucket, as a queue: requests leave at a constant rate, one every `interval` ms. A request
    -- waits for its slot. When `capacity` requests already wait, the bucket is full and refuses.
    -- KEYS[1] the time the bucket is empty again: rl:{user:42}:lb
    -- ARGV[1] now, ms    ARGV[2] interval, ms    ARGV[3] capacity
    -- Returns {allowed, retry after ms, remaining, delay ms}.
    local now = tonumber(ARGV[1])
    local interval = tonumber(ARGV[2])
    local capacity = tonumber(ARGV[3])
    local empty_at = tonumber(redis.call('GET', KEYS[1]) or '0')
    local slot = math.max(empty_at, now)
    local wait = slot - now
    local max_wait = (capacity - 1) * interval
    if wait > max_wait then
      return {0, wait - max_wait, 0, 0}
    end
    redis.call('SET', KEYS[1], slot + interval, 'PX', wait + interval + 1000)
    return {1, 0, math.floor((max_wait - wait) / interval), wait}
    • Every script takes the time as an argument. The lab replays traffic on a clock it controls.
    • In production, read the Redis clock, so every gateway uses one clock.

    A token bucket refills at the average rate and holds a burst. A leaky bucket gives each request a time slot and refuses when the queue is full.

    J

    Local and global limits

    two tiers
    1,000 requests from one client in one momentGateway 1local bucketin memory, no I/O429Gateway 2local bucketin memory, no I/O429Gateway 3local bucketin memory, no I/O429Redis: the global count per clientone script per request the local bucket admittedLab: a local bucket of 50 refused 950 of 1,000 in memory.Redis saw 50 calls, not 1,000.
    local first, then Redispseudo code
    allow(client, now):
      IF the local bucket refuses: RETURN refuse   // no network call1
      RETURN redis_script(client, now)             // the global count
    1. 1Refused in memory. Redis never sees this request.
    Tested source Go: two tiers
    Go: two tiersgo
    // Allow implements the two tiers.
    func (t *Tiered) Allow(ctx context.Context, client string, now time.Time) (Decision, error) {
      if d := t.Local.Allow(client, now); !d.Allowed {
        return d, nil
      }
      return t.Global.Allow(ctx, client, now)
    }
    • Set the local limit above a fair share. It stops floods; the global count stays exact.
    • Or take tokens from Redis in batches of 10 and spend them locally. Fewer calls, slightly less exact.

    Each gateway checks an in-memory bucket first. Only the requests it admits pay for a Redis call, so a flood stops in memory.

    K

    When Redis is down

    fail open or fail closed
    modeclients seestatus
    Fail open200; no global limitPublic reads
    Fail open, local tier on200 up to the local cap, then 429Approved
    Fail closed503 with Retry-AfterLogin, paid upstream
    Wait for Redis, no timeoutEvery request hangsNot approved
    the gateway's choicepseudo code
    decision = limiter(client, now)    // with a short timeout1
    IF the limiter fails:
      IF this route fails open2: log it; serve the request
      ELSE: RETURN 5033, Retry-After: 1
    1. 1Keep it well below the latency budget of the route. A slow limiter must not slow every request.
    2. 2An outage of the limiter does not become an outage of the API.
    3. 3503, not 429: the client did nothing wrong.
    Tested source Go: fail open or closed
    Go: fail open or closedgo
    if err != nil {
      if mode == FailOpen {
        log.Warn("rate limiter unavailable; failing open", "client", c, "err", err)
        next.ServeHTTP(w, r)
        return
      }
      log.Error("rate limiter unavailable; failing closed", "client", c, "err", err)
      w.Header().Set("Retry-After", "1")
      http.Error(w, "rate limiter unavailable", http.StatusServiceUnavailable)
      return
    }

    Tested: with Redis unreachable, fail open answered 200 and fail closed 503. With the local tier on, fail open still refused 7 of 10 from one client.

    I decide the failure mode per route in advance: open for public reads, with the local cap still on; closed for login and paid upstreams.

    L

    Failure cases

    what breaks, and what saves you
    eventresultwhy it stays safesaved by
    Redis goes downNo global count.The chosen fail mode applies. Fail open still keeps the local cap.Local tier
    Redis gets slowEvery request waits on the limiter.A short timeout, well below the route's latency budget, then the fail mode.Timeout
    Redis fails overRecent counts are lost.A client gets up to one extra window. Acceptable for a rate limit; not for billing.Replica
    200 gateways ask at onceA race on the count.The script runs as one step. Exactly the limit passes.Script
    Gateway clocks driftRefill time could go negative.The bucket never moves its last time backwards. Better: use the Redis clock.TIME
    Refused clients retry at onceA retry storm.Retry-After plus jitter. The local tier refuses the storm in memory.Retry-After
    Gateways scale out with local limits onlyEach copy allows the full limit.The global count in Redis does not change with the number of copies.Redis
    A burst at the window boundaryTwice the limit within 100 ms.Use a sliding window or a token bucket for that route.Sliding window
    One huge tenant on one keyOne cluster slot gets all its calls.The local tier absorbs most calls. Split the key per route.Local tier
    M

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1In memory2+ Redis script3+ local tier4+ Redis Cluster5+ per regionmore load →
    Capacity against demand1001k10k100k1M10MDemand, average: 1,389 limit decisions per secondDemand, average1,389Demand, 10× peak: 13,889 limit decisions per secondDemand, 10× peak13,889Redis script, lab: 25,000 limit decisions per secondRedis script, lab25,000Plain INCR, lab: 55,000 limit decisions per secondPlain INCR, lab55,000Local bucket, lab: 3.5 millionLocal bucket, lab3.5 millionlimit decisions per second, log scale
    stepaddit handlesmove up when you see
    1A limit in memory on each instance: the limit divided by the number of instances.About 3.5 million decisions a second, with no network call.Instances scale in and out, or the balancer spreads clients unevenly. Clients get the wrong limit.
    2One Redis and one script per request, as on this sheet.About 25,000 decisions a second on one Redis in the lab.Floods of refused requests use Redis capacity, or the round trip matters.
    3A local tier in front of Redis.A flood stops in memory. Redis sees only what the local tier admits.Real traffic alone nears what one Redis can do.
    4Redis Cluster, with each client's keys under one hash tag.Capacity grows with shards. Each check stays on one shard.Users in several regions, and a cross-region round trip per request is too slow.
    5Per-region limits. Each region enforces a share and syncs counts in the background.Every check stays in the region. The global limit becomes approximate.Top of the ladder.

    Demand example: 5 million requests an hour ÷ 3,600 s = 1,389 a second. Lab numbers come from one loaded laptop, so read them as orders of magnitude. A Redis server on its own host does more.

    I start with one Redis and one script per request. I add a local tier when floods cost Redis calls, and a cluster when the real traffic outgrows one node.

    N

    Drill

    predict, then reveal

    0 of 9 known

    1. A fixed window allows 100 requests a minute. What is the most a client can send in 1 second?

    2. Why one Lua script, and not GET from the gateway, then INCR?

    3. Why not MULTI and EXEC instead of a script?

    4. Token bucket or leaky bucket?

    5. Redis is down. Fail open or fail closed?

    6. 1 million clients, 100 requests a minute each. Memory for a sliding log, and for a sliding counter?

    7. A fixed window of 10 s refuses a request at 12.3 s. What is Retry-After?

    8. 10 gateway copies each keep an in-memory limit of 100 a second, with no Redis. What can one client get?

    9. What is the difference between a rate limit and a quota?

    O

    Numbers to say

    measured, derived
    Redis script
    About 25,000 limit decisions a second; 22,000 to 32,000 by algorithm.
    plain INCR
    About 55,000 a second. A script costs about twice an INCR.
    in memory
    About 3.5 million decisions a second.
    boundary
    A fixed window admits up to 2× the limit across a boundary. Lab: 39 in 10 s against 20.
    counter error
    Sliding counter, same burst: 24 in 10 s against 20.
    memory
    Log: 1 entry per admitted request. Counter: 2 integers. Bucket: 2 fields.
    Retry-After
    Whole seconds, rounded up: 7.7 s becomes 8.

    Redis 8 and Go 1.26 on an 8-core laptop shared with other jobs, 32 callers, median of 5 runs. A hot client key and 10,000 keys measured about the same on one node.