An Introduction to Rate Limiting Algorithms

Rate limiting protects a service from being overwhelmed, enforces fair use across clients, and caps the cost of abusive or buggy callers. The mechanism is simple to state — "allow at most N requests per window" — but the algorithm you pick determines how bursts are handled, how much memory you spend, and how accurate the limit is under load. This article walks through the five algorithms you'll actually encounter, their tradeoffs, where to put the enforcement, and how to extend them across many machines.

The five algorithms

Token bucket

A bucket holds up to capacity tokens and refills at a steady refillRate tokens per second. Each request removes one token; if the bucket is empty, the request is rejected (or queued). The key property is that unused capacity accumulates up to the bucket size, so a client that has been idle can spend a burst all at once, then is throttled down to the sustained refill rate.

function allow(key):
    b = store.get(key) or { tokens: capacity, last: now() }
    elapsed = now() - b.last
    b.tokens = min(capacity, b.tokens + elapsed * refillRate)
    b.last   = now()
    if b.tokens >= 1:
        b.tokens -= 1
        store.set(key, b)
        return ALLOW
    store.set(key, b)
    return DENY

Token bucket is the workhorse default. It allows configurable bursts (via capacity) while bounding the long-run average, and it stores only two numbers per client. Note that you do not need a background timer to add tokens — you lazily compute the refill from the elapsed time on each request.

Leaky bucket

The leaky bucket models a queue that drains at a fixed rate. Requests enter a FIFO queue; the queue "leaks" (processes) at most leakRate per second. If the queue is full, new requests are dropped. Where token bucket allows bursts to pass through immediately, leaky bucket smooths output to a constant rate — useful when the thing you are protecting (a payment processor, a downstream API) cannot tolerate spikes.

function allow(key):
    q = store.get(key) or { level: 0, last: now() }
    leaked = (now() - q.last) * leakRate
    q.level = max(0, q.level - leaked)
    q.last  = now()
    if q.level < capacity:
        q.level += 1
        store.set(key, q)
        return ALLOW
    store.set(key, q)
    return DENY

Implemented this way (as a counter rather than a real queue), leaky bucket is nearly identical in cost to token bucket. The practical difference is intent: token bucket permits a burst up to the bucket size; leaky bucket enforces a steadier outflow and rejects rather than absorbs sudden spikes.

Fixed-window counter

Divide time into fixed windows (e.g. each calendar minute) and keep one counter per client per window. Increment on each request; reject once the counter exceeds the limit; the counter resets when the window rolls over.

function allow(key):
    window = floor(now() / windowSize)
    k = key + ":" + window
    count = store.incr(k)
    if count == 1:
        store.expire(k, windowSize)
    return count <= limit ? ALLOW : DENY

This is the cheapest algorithm to run — a single integer and an atomic increment per client. Its weakness is the boundary burst: a client can send limit requests in the last instant of one window and another limit at the start of the next, achieving up to 2 × limit requests in a span shorter than one window. For coarse, forgiving limits this is fine; for tight ones it is a real loophole.

Sliding-window log

Keep a timestamp for every request in a sorted structure. On each request, drop timestamps older than one window, then allow only if the remaining count is below the limit. This is exact — the boundary burst disappears entirely because the window genuinely slides with each request.

function allow(key):
    log = store.get(key) or []
    cutoff = now() - windowSize
    log = [t for t in log if t > cutoff]   # drop expired
    if len(log) < limit:
        log.append(now())
        store.set(key, log)
        return ALLOW
    store.set(key, log)
    return DENY

The cost is memory: you store one timestamp per request in the window, so a client allowed 10,000 requests/minute needs room for 10,000 entries. Accuracy is perfect; scalability is the problem.

Sliding-window counter

This is the pragmatic compromise. Keep fixed-window counters (cheap) but approximate a sliding window by weighting the previous window's count by how much of it still overlaps the current sliding window.

function allow(key):
    now_w   = floor(now() / windowSize)
    prev    = store.get(key + ":" + (now_w - 1)) or 0
    curr    = store.get(key + ":" + now_w) or 0
    # fraction of the previous window still inside the sliding window
    overlap = 1 - (now() mod windowSize) / windowSize
    estimate = prev * overlap + curr
    if estimate < limit:
        store.incr(key + ":" + now_w)
        return ALLOW
    return DENY

It uses two counters per client instead of a full log, eliminates most of the fixed-window boundary burst, and is accurate enough for almost all production traffic. The estimate assumes the previous window's requests were spread evenly, so it can be slightly off, but the error is small and bounded. This is what many API gateways use by default.

Choosing: burst tolerance vs. memory

Where to enforce

The same algorithm behaves very differently depending on where it runs.

A mature system layers these: a generous gateway limit for blanket protection, plus targeted service-level limits on the expensive paths.

Distributed rate limiting

An in-memory counter only limits one process. With N gateway instances behind a load balancer, a per-instance limit of L silently becomes N×L globally. To enforce a single global limit you need shared state, typically Redis, with atomic operations so concurrent instances don't race.

The critical detail is atomicity. A naive GET, check, then SET has a race window where two instances both read "9 of 10 used" and both allow a request. Solve it by making the read-modify-write atomic — an INCR for fixed window, or a Lua script for token/sliding-window logic so the whole computation runs in one round trip under Redis's single-threaded execution.

-- atomic fixed-window in Redis (Lua)
local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])  -- windowSize
end
if current > tonumber(ARGV[2]) then          -- limit
    return 0   -- DENY
end
return 1       -- ALLOW

Other distributed concerns worth planning for:

Practical takeaway

Start with token bucket or sliding-window counter at the gateway: both are O(1) per client, handle bursts sensibly, and cover the vast majority of needs. Reach for leaky bucket only when a downstream demands a smooth output rate, and for the sliding-window log only when you need exactness on a low-volume path. The moment you run more than one instance, move the state into a shared store and make every update atomic — and decide deliberately whether you fail open or closed. Finally, always return 429 Too Many Requests with a Retry-After header so well-behaved clients can back off instead of retrying blindly.

rate-limitingsystem-designdistributed-systemsapibackend
← All articles