โ† back to journal

The Rule Was the Strict Part

2026-10-08 ยท Kit Wickham

"100 requests per minute" sounds like one rule. There are four common ways to enforce it, and they disagree:

I wanted to know how far apart they really are, against an attacker and against ordinary clients, so I simulated them. The answer I didn't expect was that the cheap approximations aren't the main problem. The rule is. Taken literally, "at most 100 in any 60 seconds" rejects 29% of the requests from a client that averages 80 a minute in bursts, and no limiter, even one that knows the future, can serve more of that client without letting some minute go over 100. Every algorithm that serves more is quietly enforcing a looser rule. Two smaller findings: the sliding-window counter is matched or beaten on both counts, in 19 of 20 cases, by a plain token bucket, and a sliding log that also records rejected requests can lock out a client using a quarter of its quota.

The setup

One key, a limit of 100 per 60 seconds, timestamps in microseconds. Every limiter sees exactly the same arrivals. For each admitted request I check the real rule against that limiter's own history: how many requests it admitted in the 60 seconds before. That gives the busiest real minute, and for each decision whether the literal rule would have agreed. Client traffic comes in five shapes, each run at 30%, 50%, 80% and 100% of the limit on average, 20 runs of 2,000 minutes each:

Three checks before trusting it. The sliding log under steady traffic is a textbook queue in disguise: each admitted request holds one of 100 "slots" for exactly 60 seconds, which makes it an Erlang loss system, and the share served should be 1 minus Erlang's B formula. At 80%, 100% and 120% of the limit the simulation gave 99.61%, 92.41% and 80.35% against 99.60%, 92.43% and 80.37%. The fixed window should serve E[min(N, 100)] / E[N] with N Poisson; it gave 99.94%, 96.00% and 83.21% against 99.94%, 96.01% and 83.23%. And the token bucket and GCRA admitted exactly the same number of requests in all 400 runs where I ran both.

The first version of these checks failed by 4 to 5 standard errors. The cause was my random number generator, a 64-bit algorithm squeezed into 32-bit arithmetic. With a proper 32-bit one the gaps went away.

The attacker

An attacker who waits quietly and then floods gets, in the worst 60 seconds:

LimiterWorst 60 secondsHow
Sliding log100It's the rule.
Fixed window200100 just before :00, 100 just after.
Sliding-window counter100 ร— (1 + ฯ†), up to 200Flood starting a fraction ฯ† into a minute.
Token bucket, burst BB + 99Empty a full bucket, then take the refill.

The sliding-window counter one is worth spelling out. Its estimate assumes last minute's requests were spread evenly. If they all came at the very end, the estimate fades them out while they're all still inside the window. Flood at 59 seconds past the minute: the counter admits 100 at once, then lets the new minute's count grow as last minute's weight fades, and just before the next 59-second mark there are nearly 200 in the window. Starting at 30 seconds the worst is 150. The simulation matched the formula at every one of 51 starting points.

The honest client

Here is the share of an honest client's requests served when it averages 80% of the limit, with the busiest real minute in brackets:

Traffic at 80%Sliding logFixed windowSliding-window counterToken bucket B=50Token bucket B=100
steady99.6% (100)99.9% (116)99.8% (113)100% (118)100% (118)
small bursts88.9% (100)94.9% (175)91.4% (142)97.6% (149)99.7% (196)
page loads71.0% (100)81.6% (200)74.3% (173)77.3% (149)89.9% (199)
big bursts56.2% (100)66.3% (200)58.3% (186)54.2% (149)71.9% (199)
on/off63.0% (100)70.9% (200)65.4% (170)70.1% (149)77.8% (199)

Mean of 20 runs of 2,000 minutes; standard errors at most 1.5 points. Busiest minute: the largest number admitted in any 60 seconds, averaged over runs.

The sliding log, the only limiter that never breaks the rule, is the strictest in every row but one (with big bursts the B = 50 bucket serves slightly less, 54.2% against 56.2%). For page loads at 80% it rejects 29% of the client's requests; even at 30% of the limit it rejects 9%. Bursts are the reason. At 80% that's four page loads a minute on average, and both the number per minute and the size of each vary a lot, so minutes over 100 are routine.

It isn't the log's fault. Admitting a request whenever the last 60 seconds have room is the best possible policy for this rule: no schedule, even one chosen with the whole future known, admits more. That's a classic result about choosing intervals of equal length, and I checked it by brute force on 3,000 small random cases (it was never beaten; a deliberately weakened version was beaten in 1,621). So every limiter that served more in the table did it by letting some minute go over 100, which the brackets show.

What about a token bucket set up so it can never break the rule? That needs burst plus one minute of refill to fit within 100, for example B = 40 refilling at 60 a minute. The best such bucket served 59.8% of the page-load client, against the log's 71.0%. Buckets that obey the rule pay for it twice, a small burst and a slower refill.

Two panels. Left: share of an honest page-load client's requests served (it averages 80 per minute in bursts of about 20) against the worst minute an attacker can get. The token bucket curve rises from 9% served at burst size 1 (worst minute 100) through 60% at B=25 (124), 77% at B=50 (149), 90% at B=100 (199) to 97% at B=200 (299). The exact sliding log sits at 100 and 71%. The best bucket that never breaks the rule sits at 99 and 60%. The fixed window sits at 200 and 82%, and the sliding-window counter at 200 and 74%, both below the token bucket curve. Right: share of requests served over 200 minutes, after one 200-request spike at minute 5, against the client's steady rate from 0.1 to 0.9 of the limit, for a sliding log that also logs rejected tries. With no retries it stays at 84 to 98%. With up to 2 tries a second apart it falls from 97% at 0.6 to 6% at 0.8; with up to 3, from 96% at 0.45 to 8% at 0.6; with up to 5, from 93% at 0.3 to 7% at 0.4; with up to 10, from 83% at 0.2 to 4% at 0.3. A sliding log that doesn't count rejects stays near 95 to 99% throughout.

Left: page loads at 80% of the limit, attacker's worst minute from the flood test. Right: steady client, 20 runs per point.

The sliding-window counter

The counter sits between the fixed window and the log in the table, which looks like a sensible compromise until you set it next to a token bucket. Its worst minute is 200. A token bucket with B = 75 has a worst minute of 174 and served at least as much in 19 of the 20 combinations of traffic shape and load, and the 20th was within 0.1 point (74.1% against 74.2%). With B = 50 the worst minute is 149, and it served at least as much in 14 of the 20 (it loses on the big bursts, and on page loads at 30% and 50%). In the left panel of the figure, the counter and the fixed window both sit under the token bucket curve.

Where the counter does better is the typical overshoot under mild bursts: for small bursts at 80% its busiest real minute was 142, against 149 for B = 50 and 174 for B = 75. If what you care about is how far ordinary traffic drifts over, rather than what an attacker can get, that's a real advantage.

Cloudflare measured the counter on 400 million requests from 270,000 sources and reported that 0.003% were wrongly allowed or rate-limited. That's a measurement of their traffic, most of which never gets near a limit, and far from the limit every method agrees. Near it, measured against its own history, my numbers are much larger. For a steady client at 80%, 0.06% of decisions were wrong rejects and 1.0% wrong admits. For page loads at 80%, 12.0% and 8.1%. The counter errs in both directions, because it can't tell whether last minute's requests came early or late.

Counting the rejects

A common way to build a sliding log in Redis is a sorted set per key: drop entries older than 60 seconds, add the new request, count, and reject if the count is over the limit. Done in that order, the rejected request stays in the set and counts against the client. For a fixed window that makes no difference (the counter is already at the limit and resets on schedule anyway; in my runs the results were identical). For a sliding window it changes everything once clients retry.

I gave a steady client a single spike of 200 requests at minute 5 and had it retry rejected requests once a second, up to m tries. If rejects count, a locked-out client's every try is logged, so the log holds about m times its normal traffic. If that stays well above 100, the window never empties and the client stays locked out:

Tries per requestNever locked (of 20 runs)Always locked (of 20 runs)
1 (no retries)up to 90% of the limitnever
2up to 60%from 80%
3up to 45%from 70% (19 at 60%)
5up to 30%from 45% (19 at 40%)
10up to 20%from 30% (17 at 25%)

Where all 20 runs locked, the client got 1% to 6% of its requests served over the 200 minutes. The lockout point sits somewhat above "tries ร— rate = limit", at roughly 1.6 to 3 times it (higher with more retries), because a locked request's tries arrive in a clump and the window has to stay full at every moment to hold the lock. With rejects not counted, the same clients were served 95% to 99% at every rate and retry count. A sliding-window counter that counts rejects behaved the same way, locking slightly earlier.

The fix is one line: don't record a request you rejected. (If you want to punish clients that hammer a closed door, do it with a separate, explicit penalty, not as a side effect of the log.)

Splitting the limit across servers

Another common shortcut is to give each of K servers its own limit of 100 / K and route requests at random. With an exact log on each server, the steady client at 80% was served 99.6% on one server, 95.0% on 4, 87.9% on 10 and 80.2% on 20: random routing makes some servers' shares busier than others. For page loads the loss was smaller (71.0% to 67.7% on 10 servers), because the bursts were already doing most of the rejecting. Per-server token buckets with B = 100 / K lost less, from 100% to 99.6% on 10 servers.

What I'd take from it

Caveats: these are synthetic traffic shapes, one key at a time, and I modeled the splitting as random routing with no shared state. Real gateways that sync counts between servers trade a different error, admitting too much during the sync delay, which I didn't measure here. (I measured it later: Every Gateway Thought There Was Room.)