GCRA vs Token Bucket Precision
Both algorithms admit the same traffic in the steady state, so the choice comes down to three practical differences: how exactly each can answer โwhen may I retry?โ, how each accumulates error over millions of operations, and how much state each keeps per key. This comparison builds on the mechanics in the GCRA and virtual scheduling guide and the token bucket implementation page.
The problem in concrete numbers
Publish 600 requests per hour โ one every 6 seconds. A client exhausts its allowance and asks how long to wait.
A token bucket stores tokens = 0.37 and a refill rate of 0.1667 tokens per second. The wait is (1 โ 0.37) / 0.1667 = 3.78 s, rounded up to 4. That figure is only as accurate as the stored float, which has been through thousands of read-multiply-write cycles, each rounding at the seventeenth significant digit and each also rounding the elapsed milliseconds.
GCRA stores TAT = 1751899203780 โ a millisecond timestamp. The wait is (TAT โ ฯ) โ now, an integer subtraction with no accumulated error at all. Over a year of continuous traffic the token bucketโs drift is small but real; GCRAโs is exactly zero because time, not a counter, is the state.
Decision matrix
| Criterion | Token bucket | GCRA |
|---|---|---|
| State per key | 2 fields (float + timestamp) | 1 integer timestamp |
| Memory at 10 M keys | ~1.2 GB in Redis hashes | ~0.9 GB in Redis strings |
Retry-After accuracy |
Derived from a float; drifts slightly | Exact integer arithmetic |
| Partial / fractional cost | Natural โ deduct 0.5 tokens | Awkward โ scale the interval instead |
| Weighted operations | consume(cost) reads naturally |
TAT += T ร cost works but reads less obviously |
X-RateLimit-Remaining |
Directly available | Derived: floor((ฯ โ (TAT โ now)) / T) |
| Behaviour under retry storms | Safe if denial does not consume | Safe if denial does not advance TAT |
| Idle-then-burst | Refills to capacity | Tolerance restores identically |
| Mental model for a team | Widely known | Needs one paragraph of explanation |
| Library availability | Everywhere | Common but less ubiquitous |
The honest summary: GCRA wins on answer quality and state size; token bucket wins on familiarity and on fractional costs. For a public API where Retry-After correctness matters and keys number in the millions, GCRA is the better default. For an internal service with weighted operations and a team that already understands buckets, the difference is not worth a rewrite.
Where the two actually diverge
Run 100,000 requests at a limit of 10 per second through both implementations and the admitted counts match to within one. The divergence shows up in three places.
The reported wait. A token bucketโs Retry-After is computed from tokens, which is a float that has absorbed rounding from every previous refill. In practice the error is well under a second โ but it is systematically biased, because implementations clamp with max(1, ceil(...)) and the ceiling always rounds against the client.
Fractional costs. Charging 0.25 units is trivial with tokens and clumsy with a schedule: you shift the theoretical arrival time by 0.25 ร T, which works but produces a schedule where โone requestโ is no longer the unit of reasoning.
Remaining, as a header. Clients read X-RateLimit-Remaining and pace themselves against it. A token bucket has the number already; GCRA must derive it from the schedule gap. Compute it inside the same atomic script โ deriving it from a second read produces a value that disagrees with the decision that was just made.
Step-by-step: choosing between them
-- Same limit, both algorithms, so you can A/B them behind one interface.
-- Token bucket: two fields, float refill.
local function token_bucket(key, cap, rate, now, cost)
local st = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(st[1]) or cap
local ts = tonumber(st[2]) or now
tokens = math.min(cap, tokens + (now - ts) / 1000 * rate)
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil(cap / rate * 1000) + 1000)
-- Retry estimate carries the float's rounding with it.
local retry = allowed and 0 or math.ceil((cost - tokens) / rate * 1000)
return { allowed and 1 or 0, math.floor(tokens), retry }
end
-- GCRA: one field, integer schedule.
local function gcra(key, T, tau, now, cost)
local tat = tonumber(redis.call('GET', key)) or now
local allow_at = tat - tau
if now < allow_at then
return { 0, 0, allow_at - now } -- exact wait, TAT untouched
end
local new_tat = math.max(now, tat) + T * cost
redis.call('SET', key, new_tat, 'PX', math.ceil(tau + T * cost))
local remaining = math.floor((tau - (new_tat - now)) / T)
return { 1, math.max(0, remaining), 0 }
endGotchas and edge cases
- Comparing admitted counts proves nothing. Both algorithms admit the same traffic; a benchmark that only counts accepts will show them as identical and hide the difference that matters.
- A token bucket with second-resolution timestamps is much worse than GCRA. Storing
tsin whole seconds quantises the refill and can double the effective error. If you keep buckets, keep milliseconds. - GCRA with a zero tolerance is not a token bucket with capacity 1. With
ฯ = 0, two requests in the same millisecond always reject the second; a capacity-1 bucket behaves the same way, but teams often setฯ = 0by accident when they mean โno burst above the rateโ. - Deriving remaining from a second call. Whichever algorithm you pick, one atomic call must return decision, remaining, and wait together.
- Float comparison at the boundary.
tokens >= costwithtokens = 0.9999999999rejects a request that should pass. Compare with a small epsilon, or use GCRA and avoid the class of bug. - Migrating live keys. The two state formats are not interchangeable; a rollout must either dual-write, or accept that every key starts fresh โ which grants everyone one burst at cutover.
Verification and testing
# Run both algorithms over the same arrival trace and compare their answers.
import math, random
LIMIT, PERIOD_MS, BURST = 10, 1000, 10
T, TAU = PERIOD_MS / LIMIT, BURST * (PERIOD_MS / LIMIT)
def trace(n=100_000, seed=7):
random.seed(seed); t = 0
for _ in range(n):
t += random.choice([5, 20, 100, 400]) # bursty, then idle
yield t
tokens, ts, tat = float(LIMIT), 0, 0
tb_admitted = gcra_admitted = 0
tb_wait_err = 0.0
for now in trace():
# token bucket
tokens = min(LIMIT, tokens + (now - ts) / 1000 * (LIMIT / (PERIOD_MS / 1000)))
ts = now
if tokens >= 1:
tokens -= 1; tb_admitted += 1
else:
advertised = math.ceil((1 - tokens) / (LIMIT / (PERIOD_MS / 1000)) * 1000)
true_wait = (1 - tokens) / (LIMIT / (PERIOD_MS / 1000)) * 1000
tb_wait_err += advertised - true_wait # always >= 0: rounds against the client
# gcra
if now >= tat - TAU:
tat = max(now, tat) + T; gcra_admitted += 1
print(tb_admitted, gcra_admitted) # within 1 of each other
print(round(tb_wait_err / 1000, 1), "seconds of over-advertised wait")The two admitted counts land within one of each other; the accumulated over-advertised wait is the number worth looking at, because it is time your clients spent not sending requests they were entitled to send.
Frequently Asked Questions
Is GCRA more accurate at admitting traffic?
No โ both admit essentially the same requests. GCRA is more accurate at answering: the wait it reports is an exact integer subtraction rather than an estimate derived from a floating-point token count, so clients that obey Retry-After resume at the right moment instead of a rounded-up one.
Which uses less memory?
GCRA, by roughly a third at scale: one integer per key instead of a two-field hash. At ten million active keys that is a few hundred megabytes of Redis, which is real but rarely decisive on its own.
Can I use GCRA with weighted or fractional costs?
Yes: advance the schedule by T ร cost. It works correctly, but reasoning about "half a request" as a fraction of an emission interval is less intuitive than deducting half a token, so teams doing heavy cost weighting often stay with buckets.
Should I migrate an existing token bucket to GCRA?
Only for a concrete reason โ clients materially harmed by inaccurate retry advice, or key counts large enough that state size matters. The state formats are incompatible, so a cutover either needs dual writes or resets every key, which grants every client one extra burst at the moment of the switch.
Related
- GCRA & Virtual Scheduling โ the parent guide with the full algorithm.
- Implementing GCRA in Redis Lua โ the production script and its tests.
- Token Bucket Implementation โ the algorithm on the other side of this comparison.
- Algorithm Tradeoff Analysis โ the wider comparison across all five families.