Skip to main content

Rate Limiter: Shared State and Atomicity Deep Dives

Editorial

How do you ensure consistent rate limiting across multiple gateway instances?

Level: Senior

You run 10 gateway instances behind a load balancer. A client with a limit of 100 requests a minute sends 200, and the load balancer spreads them evenly: 20 to each gateway. If every gateway keeps its own buckets, each sees 20 requests, well under 100, and all 200 pass — twice the limit. With 50 gateways, the same client could get 50 times its limit.

Counting has to happen in one place that every gateway shares.

What the Shared Store Has to Do

  1. Answer in one round trip. The check is on the path of every request.
  2. Update a bucket atomically. Read, decide and write must happen as one step, or two gateways can spend the same token.
  3. Forget on its own. A bucket has to disappear once it stops mattering, without a cleanup job.

Notice what it does not need: durability. A bucket that is lost is a full bucket, so the worst a lost write can do is let a few extra requests through.

Why Redis, and Why Not the Others

Redis does all three: it keeps everything in memory, runs a short script atomically (the next deep dive), and expires keys by itself. A Redis Cluster spreads the buckets over shards.

A relational database can make the update atomic too, with one UPDATE … RETURNING per check. But it makes every check a durable write — logged to disk before it answers — which is exactly the cost a rate limiter does not need, at 3 million checks a second.

Memcached has atomic incr/decr and compare-and-swap. That is enough for a fixed-window counter — Cloudflare's rate limiter counts in a memcached fork — but a token bucket's read-decide-write would need a compare-and-swap loop that retries whenever another gateway got there first, and the retries pile up on the busiest keys.

Scaling the Store

A Redis Cluster splits keys into 16,384 hash slots and gives each shard a range of them. Every bucket lives in exactly one slot, so every check of a client's key goes to the same shard, and all of them run on that shard's single command thread, one after another. Capacity grows by adding shards: the estimate needs 50 primaries for 3 million checks a second, each with a replica.

What "One Count" Can Promise

On one primary, every gateway sees every update, because all of them go through the same thread. Replication to the replica, however, is asynchronous. If a primary fails, the replica that takes over may not have its last writes; Redis's documentation is explicit that "acknowledged writes can still be lost during a failover". For the buckets on that shard, the last moments are forgotten: some clients get a few extra requests, then everything is normal again. WAIT makes the window smaller but does not close it.

For a rate limiter that is an acceptable trade. For billing or quotas that must never be exceeded it is not, which is why those keep their totals in a durable store.

The Alternative: Local Counts, Synced Periodically

Each gateway counts locally and sends its counts to a central store every second; the central store adds them up and sends back each client's total. Gateways keep working if the central store is down, and the hot path has no network call.

The cost is accuracy. Between syncs, every gateway admits up to the full limit on its own: with 10 gateways and a limit of 100 a minute, a client can get 1,000 requests through before the first sync catches it. That is fine for coarse limits ("no more than 50,000 a second from one tenant"), not for per-client ones. Token leasing, in the deep dive on hot keys, is a middle ground that never overshoots.

Across Regions

All of this assumes the gateways share one Redis Cluster in one region. A limit that has to hold across regions is a different problem — a round trip between regions takes tens of milliseconds, too slow for the hot path — and it is one of the Staff-level discussion topics.

Ten gateways, one bucket: the limit holds. A client limited to 100 a minute sends 200, spread over 10 gateways. Each of the 10 API GATEWAYS gets 20 of the 200. All 200 checks go to ONE REDIS PRIMARY, the shard that owns this key's slot, where they queue for one thread that takes one check at a time against the single bucket rl:key:ak_abc123. Of the client's 200 requests, 100 pass and 100 get 429. The primary copies its writes to ITS REPLICA asynchronously (a dashed arrow): a failover can lose the last writes. One bucket, one count: 100 of the 200 pass, whichever gateways they reach.

How do you ensure atomic check-and-update of token bucket state under concurrent requests?

Level: Senior

A client has one token left. Two of its requests arrive at the same moment on two gateways. Gateway A reads "1 token", allows its request and writes 0. Gateway B read "1 token" before A's write landed; it allows its request too, and also writes 0. Two requests passed on one token.

One token, two requests through. Read, decide and write as three steps: two gateways can interleave them. A sequence across GATEWAY A, REDIS and GATEWAY B, starting with 1 token left in the bucket. Gateway A reads the bucket and gets 1 token. Gateway B reads the bucket and also gets 1 token. Each decides on its own: 1 token, allow. A writes 0 tokens; B writes 0 tokens. The bucket ends at 0 tokens. B read the bucket before A wrote it back: two requests passed on one token.

The Problem: Read, Decide, Write

A check is three steps: read the bucket, decide (and compute the new token count), write it back. If the steps of two checks can interleave, both can read the same state. In a test, 50 threads doing the three steps as separate Redis commands against a bucket of 100 tokens let through far more than 100 requests; the same checks done by a script let through exactly 100.

How Redis Makes It Atomic

Redis executes commands on one thread, one at a time, and a Lua script runs the same way. In Redis's words: "While executing the script, all server activities are blocked during its entire runtime." A script that reads, decides and writes is therefore atomic — no other command runs in the middle — without any lock.

One script at a time: one token, one request. The same race, with the read, the decision and the write in one script. A sequence across GATEWAY A, REDIS and GATEWAY B, starting with 1 token left. Both gateways send one script call (EVALSHA) at the same moment. Redis runs A's script first: it reads 1, allows and writes 0, and answers {1, 0, 0}: allowed. B's script waits its turn; then it reads 0 and rejects, and answers {0, 0, 600}: rejected, the next token in 600 ms. The bucket ends at 0 tokens. Redis runs one script to its end before the next: nothing comes between read and write.

The Script

-- One token bucket, checked and updated in one atomic step.
-- KEYS[1]  the bucket, e.g. rl:key:ak_abc123
-- ARGV     capacity, tokens added per period, period in ms, cost of this request
local capacity = tonumber(ARGV[1])
local per_ms   = tonumber(ARGV[2]) / tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

local t   = redis.call('TIME')                       -- Redis's clock: one clock for every gateway
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)   -- ms

local state  = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity        -- no key: a full bucket
local ts     = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * per_ms)

local allowed, retry_ms = 0, 0
if tokens >= cost then
  tokens, allowed = tokens - cost, 1
else
  retry_ms = math.ceil((cost - tokens) / per_ms)     -- until enough tokens are back
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil((capacity - tokens) / per_ms))  -- full by then, so no key means the same
return {allowed, math.floor(tokens), retry_ms}

What each part is for:

  • TIME: Redis's clock, not the gateway's. Many examples pass each gateway's own clock in as an argument. Gateways' clocks disagree, and then the elapsed time between two checks can come out negative — tokens taken away — or too large — tokens added twice. Clocks kept in step by NTP typically differ by milliseconds; a host whose synchronization has failed can be seconds off. In a test of that version, a client sending at half its limit through two gateways whose clocks were 2 seconds apart had 60 of its 120 requests rejected: every request that reached the gateway whose clock was behind. Calling TIME inside a script that writes is allowed because Redis replicates a script's effects (its writes), not the script itself — the default since Redis 5.0 and the only mode since 7.0.
  • Milliseconds. With whole seconds, two requests in the same second would refill nothing between them.
  • math.max(0, now - ts). If a failover moves the bucket to a replica whose clock is a little behind, the elapsed time could come out negative. Clamped, a clock step can only pause refills, never take tokens away.
  • The rate as two integers, tokens per period in milliseconds, so that "100 a minute" is exact.
  • An expiry equal to the time to refill. A missing key means a full bucket, so the key may disappear exactly when the bucket would be full again — (capacity − tokens) ÷ rate. After one request from a full 100-a-minute bucket, that is 600 ms. A fixed EXPIRE key 3600 would keep every idle bucket 60 times longer than needed; an expiry shorter than the refill time would hand out tokens too early.
  • HSET with several fields: HMSET has been deprecated since Redis 4.0.0.
  • Integers in the reply. Redis turns Lua numbers into integers on the way out, so the script returns the tokens rounded down and the retry time in milliseconds rather than a fraction of a second.
Trust the gateways' clocks, lose tokens. A client at half its limit, through two gateways whose clocks disagree. GATEWAY A's own clock reads 12:00:02; GATEWAY B's reads 12:00:00, 2 s behind A. A chart of the tokens after each check, for a request every 0.2 s that alternates between the two gateways: 9 after each check at A, −9 after each check at B. At B, elapsed = 0.2 − 2 = −1.8 s: 18 tokens are taken away, and the request is rejected. At A, elapsed = 0.2 + 2 = +2.2 s: 22 tokens come back, capped at 10. Rejected, of 120 requests at half the limit: 60 with the gateway clocks 2 s apart, every request through B; none with Redis's clock. The script calls TIME: one clock for every gateway.

Calling It

Load the script once with SCRIPT LOAD, which returns its SHA1 digest, and run it with EVALSHA and that digest, so the script's text is not sent on every call:

EVALSHA <sha1> 1 rl:key:ak_abc123 100 100 60000 1

The script cache is not part of the data: it is empty after a restart, and on a replica that has just been promoted. EVALSHA then fails with NOSCRIPT, and the client loads the script again and retries; most Redis clients do this on their own. Inside a pipeline that retry is not possible — the other commands have already gone — so clients send the full script with EVAL when pipelining.

Redis 7.0 and later offer a cleaner option: functions. FUNCTION LOAD registers a named function that Redis stores with the data, persists and replicates, and FCALL calls it by name; there is no NOSCRIPT. In a Redis Cluster, the function must be loaded on every primary.

What It Costs

About 3 µs of server time per call in our test, against 0.3 µs for a plain INCR: one Redis process ran 121,000 to 180,000 calls a second. The network round trip, a few hundred microseconds, costs far more than the script.

Alternatives

An optimistic transaction. WATCH the key, read it, compute, then MULTI … EXEC: Redis runs the transaction only if no other client changed the key since the WATCH, and otherwise returns nothing, and the client starts again. That is a correct check-and-set — in a test, two clients racing for one token got exactly one between them. But every check takes several round trips, and on a busy key the transactions keep failing and retrying.

A lock. Take a lock (SET lock NX PX 100), read, write, release. It is one round trip to take the lock and one to release it, gateways wait on each other on busy keys, and the lock's own expiry must outlast the work it protects. A script needs none of this.

One number instead of two. The generic cell rate algorithm (GCRA) stores only the time at which the bucket will next be full and makes the same decisions; the redis-cell module implements it as one command, CL.THROTTLE. It is smaller (84 bytes per key against 132 in our measurement), at the price of a less obvious formula.

How do you handle multiple rate limit rules per request?

Level: Mid

A request arrives from API key ak_abc123, which belongs to the tenant org_acme, from the address 203.0.113.42. Three rules apply: 1,000 requests a minute per IP address, 100 a minute per API key, and 10,000 an hour per tenant. All three have to be checked without tripling the latency.

One Key per Rule

Each rule has its own bucket, under its own key:

  • Per IP address: rl:ip:203.0.113.42
  • Per API key: rl:key:ak_abc123
  • Per tenant: rl:tenant:org_acme

The gateway builds the three keys and calls the token-bucket script once for each, with that rule's capacity and rate.

Sending Them Together

On one Redis server, the three calls go in one pipeline: written back to back, answered together, one round trip.

In a Redis Cluster the three keys hash to different slots — in a test, slots 10,489, 2,034 and 1,700 — and usually to different shards. One script cannot take all three: Redis rejects a script whose keys are in different slots with a CROSSSLOT error. The client sends each call to the shard that owns its key, all three at once, so the request still waits about one round trip: that of the slowest shard.

Combining the Answers

Each call returns {allowed, remaining, retry_ms}. The rule is simple: reject if any rule rejects, and wait for the longest retry.

Per IPPer API keyPer tenantDecisionRetry-After
AllowAllowAllowAllow—
AllowReject (0.6 s)AllowReject1 s
Reject (2 s)AllowAllowReject2 s
Reject (2 s)Reject (0.6 s)Reject (0.4 s)Reject2 s

The headers describe the tightest rule, the one with the fewest requests left, and the body names it.

Does a Rejected Request Still Cost Tokens?

When the per-IP rule rejects a request, the per-key and per-tenant scripts in the same round trip have already taken a token each. Two consequences: a client that keeps pushing past one limit also drains its others — usually what you want, since abuse should cost the abuser — and a rejected request still counts against its tenant.

Envoy's rate limit service behaves the same way: it counts every rule that matches and returns "a logical OR of all the individual rate limit decisions", and it has an opt-in setting to stop counting once one of the keys is already over its limit. If a rejected request must not cost the other rules anything, there are two ways:

  • Check in order, stop at the first rejection. The rules are no longer sent together, so the request pays a round trip per rule.
  • Refund. When the combined answer is no, give the tokens back to the rules that allowed it, with a second call.

We send all three together and accept the charge: one round trip, and the cost lands on clients that are already over a limit.

Three rules, one round trip, one answer. Each rule has its own bucket; the request needs a yes from all three. The GATEWAY makes one script call per key, sent together: rl:ip:203.0.113.42 (slot 2,034), rl:key:ak_abc123 (slot 10,489) and rl:tenant:org_acme (slot 1,700). The per-IP bucket answers allowed, the per-key bucket answers rejected with 600 ms to wait, and the per-tenant bucket answers allowed. The request is REJECTED: 429 with Retry-After: 1. Reject if any rule rejects, and wait for the longest retry: 600 ms, sent as 1 second. The keys sit in different slots, so the three calls go to their shards in parallel.