Token Bucket vs Sliding Window Rate Limiting
Most rate-limiting arguments are really arguments about bursts. A token bucket lets an idle client accumulate allowance and spend it at once, which is friendly to well-behaved batch clients and unhelpful when the thing being limited is password guessing. A sliding window enforces a ceiling over the trailing interval regardless of how the requests are spaced, which is exactly right for authentication and slightly annoying for a client that wanted to sync fifty records in one go.
This guide compares the two on the properties that matter, gives an atomic Redis implementation of each, and states which abuse patterns each one actually stops. It is part of the API Rate Limiting & Abuse Prevention guide within Vulnerability Patterns & Web Mitigation Strategies.
Prerequisites
- Redis 6 or newer reachable from every instance, with scripting enabled
- A decision about which identity each endpoint counts against
- Observed request-rate percentiles for the endpoints you intend to limit
- A load-testing tool that can generate a burst at a chosen instant
Expected Outcomes
- An informed choice of algorithm per endpoint class rather than one global default
- An atomic implementation that cannot be raced by concurrent requests
- No fixed-interval boundary that can be straddled to double the limit
- Memory cost per key measured at your actual key cardinality
Step 1: Compare the Behaviours That Matter
| Property | Token bucket | Sliding window (log) | Sliding window (approximate) |
|---|---|---|---|
| Burst behaviour | Allows an accumulated burst up to capacity | No burst beyond the ceiling | Nearly none |
| Boundary abuse | Not possible | Not possible | Slight over-admission near a boundary |
| Memory per key | Two numbers | One timestamp per request in the window | Two counters |
| Cost to evaluate | Constant | Proportional to requests in the window | Constant |
| Best for | General API traffic, generous ceilings | Authentication, metered actions, hard ceilings | High-cardinality capacity limits |
| Worst for | Guessing attempts, where burst is the attack | Very high request rates per key | Cases where exactness is required |
Step 2: Implement Each One Atomically
A limiter that reads, decides and writes in three round trips can be raced: two concurrent requests both read nine of ten used, both decide there is room, and both proceed. Do the whole operation in one server-side script.
-- token_bucket.lua — KEYS[1] state key; ARGV: capacity, refill/sec, now, cost
local capacity, rate, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * rate) -- refill for elapsed time
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) * 2)
return { allowed and 1 or 0, math.floor(tokens) }
-- sliding_log.lua — KEYS[1] sorted set; ARGV: window seconds, max, now, member id
local window, maxn, now, member = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), ARGV[4]
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window) -- drop what has aged out
local used = redis.call('ZCARD', KEYS[1])
if used >= maxn then
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return { 0, math.ceil(oldest[2] + window - now) } -- exact retry-after
end
redis.call('ZADD', KEYS[1], now, member)
redis.call('EXPIRE', KEYS[1], window)
return { 1, 0 }
The log variant gives an exact Retry-After for free, because it knows when the oldest request in the window ages out — a small detail that materially improves client behaviour under load.
Step 3: Choose Per Endpoint Class, Not Globally
const POLICY = {
'auth.login': { algo: 'sliding-log', window: 900, max: 10 }, // ceiling is the security property
'auth.reset': { algo: 'sliding-log', window: 3600, max: 5 },
'message.send': { algo: 'sliding-log', window: 86400, max: 200 }, // metered: exactness matters
'api.read': { algo: 'token-bucket', capacity: 120, refill: 2 }, // bursts are legitimate
'api.write': { algo: 'token-bucket', capacity: 30, refill: 0.5 },
'catalogue.list': { algo: 'sliding-approx', window: 60, max: 600 }, // high cardinality, capacity only
} as const;
The rule of thumb is short: if exceeding the number is a security event, use a sliding window; if exceeding it is merely expensive, a bucket is friendlier and cheaper.
Verification
# 1. Boundary test: the classic fixed-window doubling must not be possible.
# Send the full quota just before a minute boundary, then again just after.
python3 - <<'PY'
import time, requests
url, headers = "http://localhost:3000/api/search", {"Cookie": SESSION}
time.sleep(60 - time.time() % 60 - 1.0) # land just before the boundary
first = sum(requests.get(url, headers=headers).status_code == 200 for _ in range(30))
time.sleep(1.5) # cross the boundary
second = sum(requests.get(url, headers=headers).status_code == 200 for _ in range(30))
print(first, second, "→ sum must not exceed the per-window maximum")
PY
# 2. Concurrency test: 50 parallel requests against a limit of 10 must admit exactly 10.
seq 50 | xargs -P50 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
-H "Cookie: $SESSION" http://localhost:3000/api/export | sort | uniq -c
# Expected: 10 with 200, 40 with 429 — any other split means the check is not atomic.
The concurrency test is the one that catches a non-atomic implementation, and a non-atomic limiter fails precisely when it matters, because attack traffic is parallel by nature.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| More requests admitted than the limit under load | Read-modify-write split across round trips | Move the whole decision into one server-side script |
| Clients occasionally get double the quota | Fixed window rather than sliding | Switch to a sliding variant, or accept the doubling explicitly |
| Memory growth in the counter store | Sliding log at high cardinality, or missing expiry | Set an expiry inside the script; move read paths to the approximation |
Retry-After values are wrong |
Computed from the window length rather than the oldest entry | Return the exact expiry from the log implementation |
| Limits behave differently per instance | Local in-process fallback active because the store is unreachable | Alert on fallback activation; treat it as an incident, not a fallback |
| Legitimate batch clients constantly throttled | Sliding window applied where bursts are normal | Move that endpoint class to a bucket with a capacity sized to the batch |
Common Implementation Mistakes
Frequently Asked Questions
Which algorithm should I use for a login endpoint?
A sliding window. On authentication the ceiling per interval is the security property, and a burst allowance is a gift to whoever is guessing. Ten attempts per fifteen minutes should mean exactly that, not ten immediately followed by a steady refill that permits another ten a few minutes later. Bucket refill suits ordinary API traffic; it does not suit guessing.
Is a fixed window ever acceptable?
Only where doubling the limit at a boundary is harmless — a coarse capacity guard in front of a cache, for example. The failure mode is easy to state and easy to exploit: send the full quota at the end of one interval and the full quota again at the start of the next, and you have achieved twice the intended rate in a couple of seconds. That is unacceptable on anything security-relevant.
How much accuracy does the approximate sliding window lose?
The weighted-two-window approximation stays within a few percent for smooth traffic and errs slightly permissive for bursts near a boundary — an acceptable trade for capacity limits at high key cardinality. Where the number has to be exact, such as a hard daily ceiling on a metered action, keep the timestamp log for that key. It costs more memory and never over-admits, which is the right priority when each admitted request spends money.
Related
- API Rate Limiting & Abuse Prevention — the parent guide covering identities, budgets and failure modes
- Rate Limiting Login Endpoints Against Credential Stuffing — applying the sliding window where it matters most
- MFA Enforcement Patterns — what happens after an attacker gets past the guessing budget
- Threat Prioritization & Risk Scoring — deciding which endpoints deserve the tightest budgets first