Every Gateway Thought There Was Room
My last post compared rate limiters as if one machine saw every request. Real APIs sit behind several gateways, and "100 requests per minute per key" has to be enforced across all of them. The usual answer is a shared counter in something like Redis that each gateway syncs with every so often, because asking the store on every request is slow. That post ended by saying I hadn't measured what the gap between syncs costs. This one does.
The short version: a shared counter synced every ฮ seconds lets through the limit plus about (Kโ1)/K of whatever an attacker sends during one ฮ, where K is the number of gateways, until it hits K times the limit. With 8 gateways syncing every second, a flood at 100 times the limit gets 2.4 times the limit, and a flood at 1,000 times gets 7.3 times. So the safe sync interval depends on how fast the attacker is, not on the limit. A gateway that assumes the others are doing what it's doing stays under 1.7 times the limit at any flood rate, but it starts rejecting honest clients when traffic is unevenly spread and syncs are slow. Leases never over-admit, but on a low-rate key they either strand most of the budget or turn into a store round trip on nearly every request.
The setup
One key, a limit of 100 per 60-second sliding window, K gateways from 2 to 32. Each request lands on one gateway. Every gateway syncs with a central store every ฮ seconds (0.1, 0.3, 1, 3 or 10), at its own random offset. I made the store generous: it keeps the exact timestamp of every admitted request, and a sync is instant. The only error left is that a gateway doesn't know what the others admitted since they last pushed. Six designs:
- Central log: every request asks the store. Exact, and the baseline for everything below.
- Split: no sharing; each gateway allows 100/K on its own.
- Shared counter: admit if what the store said at the last sync, plus what this gateway admitted since, is under 100.
- Assume the others did what I did: the same, but count this gateway's admits since its last sync K times over.
- Track my share: like the previous one, but use this gateway's measured share of recent traffic instead of 1/K.
- Leases: at each sync a gateway hands back unused slots and asks for 1.5 times its recent arrivals per interval (plus one). The store counts outstanding grants as used, so it never grants past 100. A gateway admits only within its grant. A second version, leases + ask when empty, asks for nothing when it's been idle and goes to the store immediately when it runs out, at the cost of a round trip for that request. If the store says no, it waits for its next sync.
Honest traffic was a steady client at 50%, 80% or 100% of the limit, or page loads (bursts of about 10 requests within a second) at 50% or 80%, with requests spread evenly over the gateways or unevenly (gateway i gets a share proportional to 1/i, so the busiest of 8 gets 37% and the busiest of 32 gets 25%). Floods were steady random traffic at 1.5 to 1,000 times the limit, spread evenly. That's 2,815 runs: 5 seeds of 300 minutes each for honest traffic, 3 seeds of 20 to 60 minutes for floods, and a set of honest runs with a limit of 10,000 to see what changes for a busy key.
Checks before trusting it: with one gateway, the shared counter and both extrapolating designs made exactly the central log's decisions. The central log served 99.61% of a steady client at 80%, which matches the Erlang loss formula, as in the last post. With all gateways syncing at the same instants and a fixed-window counter, a heavy flood gets exactly K ร 100 per window (4.000 with four gateways), which is what you'd work out by hand. And across all 2,815 runs no lease design ever had more than 100 admitted in any 60 seconds.
Floods: the allowance is one sync interval
Admitted per minute, as a multiple of the limit, with 8 gateways syncing every second:
| flood, ร limit | 3 | 10 | 30 | 100 | 1,000 |
|---|---|---|---|---|---|
| shared counter | 1.02 | 1.14 | 1.43 | 2.45 | 7.32 |
| shared counter, fixed windows | 1.04 | 1.15 | 1.44 | 2.47 | 7.16 |
| track my share | 1.02 | 1.08 | 1.27 | 1.77 | 1.99 |
| assume the others did what I did | 1.02 | 1.08 | 1.22 | 1.44 | 1.46 |
| leases (both) | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
The shared counter's numbers collapse onto one variable: x, the attack traffic arriving in one sync interval, measured in units of the limit. A 100ร flood is 167 requests a second, so with ฮ = 1 s, x = 1.67. With syncs of 1 s or faster, all 60 combinations of gateway count, sync interval and flood rate with x up to 2 land within 4% of 1 + (Kโ1)/K ร x. With 2 gateways and x = 0.5 that predicts 1.25 and the simulation gave 1.24; with 8 gateways and x = 0.17, 1.146 against 1.135 to 1.146; with 32 gateways and x = 1.67, 2.61 against 2.61. The formula overestimates by up to 15% at ฮ = 10 s, where an interval is a sixth of the window (2.18 against 2.46 with 8 gateways at x = 1.67). Past x โ 2 it bends over and approaches K, because no gateway will admit more than 100 of its own requests in a window: 32 gateways at ฮ = 10 s and a 1,000ร flood got 30.7ร.
The reading I'd give it: when the window fills, every other gateway is working from a count that's up to one interval old, and each keeps admitting its share of the flood until it next hears from the store. That costs (Kโ1)/K of one interval's worth of attack traffic per window.

Two things I expected to help didn't. A sliding window doesn't save the shared counter: the sliding and fixed-window versions were within 5% of each other at every flood rate from 10ร up with syncs of 1 s or faster, and within 15% at 10 s. A fixed window frees the whole budget at once at the minute boundary, so every gateway sees room at the same moment. A sliding window frees it gradually, but the over-admitted requests come in clumps, and when a clump ages out all the gateways see the same hole together and fill it again. And shorter syncs only help in proportion: ฮ = 0.1 s against a 1,000ร flood is x = 1.67 again, and 32 gateways admitted 2.63ร.
Extrapolating local counts is a real fix for floods. "Assume the others did what I did" stayed at or under 1.24ร with 2 gateways and 1.68ร with 32 at every flood rate, because when traffic is spread evenly each gateway's count times K is a decent estimate of the total. Tracking each gateway's actual share was worse (up to 3.0ร with 32 gateways): in a flood, admissions are near zero most of the time, so the measured shares are noisy, and a gateway that thinks its share is large lets in too much.
What it costs honest clients
Rejected requests for a steady honest client at 80% of the limit, uneven routing, syncing every second; the central log rejects 0.4%. Brackets show the worst 60 seconds over all runs, as a multiple of the limit:
| gateways | 2 | 8 | 32 |
|---|---|---|---|
| split | 9.5% (0.96) | 27.1% (0.81) | 39.1% (0.73) |
| shared counter | 0.4% (1.04) | 0.3% (1.05) | 0.3% (1.05) |
| assume the others did what I did | 0.4% (1.02) | 1.0% (1.03) | 4.9% (1.03) |
| track my share | 0.5% (1.02) | 1.5% (1.04) | 1.5% (1.04) |
| leases | 3.4% (1.00) | 3.7% (0.96) | 35.9% (0.72) |
| leases + ask when empty | 0.5% (1.00) | 0.5% (1.00) | 0.4% (1.00) |
| โฆ round trips per request | 0.62 | 0.92 | 0.98 |
Splitting the limit is the clear loser. With uneven routing the busy gateway runs out while the quiet ones sit on unused quota: 39% rejected with 32 gateways at a load where the central log rejects 0.4%. Even with perfectly even routing it rejected 15% at 32 gateways, since random routing makes each gateway's share fluctuate.
The shared counter is almost free for honest clients. It rejects no more than the central log, and slightly less as syncs get slower, because the slack it gives attackers it also gives everyone else. With ฮ = 10 s the honest client's worst minute reached 1.15ร the limit, and page-load bursts reached 1.9ร.
Extrapolating has a cost that grows with the sync interval and with uneven routing. A gateway that gets 25% of the traffic and multiplies its own count by 32 thinks the key is eight times busier than it is. At ฮ = 1 s that's 4.9% rejected with 32 gateways; at ฮ = 10 s it's 16.4%. With even routing at ฮ = 10 s it was 5.4%. Tracking the share fixes most of the uneven-routing cost (1.5% at 32 gateways, 4.5% at ฮ = 10 s), which is the trade against its weaker flood cap.
Plain leases strand budget. With 32 gateways each asking for at least two slots per interval, up to 64 of the 100 slots are parked on gateways that may not use them, and the client loses 36%. Asking when empty fixes that completely, but for this key it's just the central log with extra steps: a store round trip on 98% of requests, because each gateway sees well under one request per interval.
A busy key changes the lease picture. With a limit of 10,000 a minute and the client at the limit with even routing, leases + ask when empty went to the store on 1 to 6% of requests and never went over the limit, though with ฮ = 10 s it rejected 4 to 6% against the central log's 0.8%. Splitting is still bad there with uneven routing (37% rejected at 32 gateways and 80% load). So leases are for keys that each gateway sees many times per interval; for the long tail of quiet keys, a lease is a round trip.
What I'd take from it
- If you run a shared counter with a sync interval, budget for the limit plus (Kโ1)/K of what an attacker can send in one interval, up to K times the limit. Kong's Rate Limiting Advanced plugin, for example, exposes this as a
sync_ratesetting, and its docs say the shared counters are accurate only when syncing on every request. - If floods matter, scale your local count by K before comparing it to the limit. It caps the damage at well under 2ร for any flood rate, at the price of rejecting some honest traffic when routing is uneven and syncs are slow. Keep the sync interval short if you do.
- Don't split the limit evenly across gateways unless routing is sticky and even. It has no attack risk and the worst honest cost of anything here.
- Leases (Google's Doorman is the well-known example, though it leases rates rather than slots) give an exact guarantee and are cheap for busy keys. For quiet keys, either accept a round trip per request or accept stranded budget.
Caveats: one key, floods spread evenly over the gateways (an attacker who can pick a gateway changes the extrapolating rule's numbers), an idealized store with exact timestamps and instant syncs, no network delay, and one simple lease-sizing policy. Real gateways with slower or lossy syncs would over-admit more, not less.