Token Bucket Refill Precision and Clock Sources

A token bucket is two numbers and one multiplication, and every production bug in one comes from the multiplication’s inputs: which clock produced now, what resolution it has, and what happens when it moves backwards. This page is the precision companion to the token bucket implementation guide, covering the clock choices that decide whether your advertised rate is what clients actually get.

The problem in concrete numbers

A limiter allows 5 requests per second and stores ts in whole seconds. Two requests arrive 200 ms apart within the same second: elapsed = 0, so no tokens refill — correct. But a request arriving 999 ms after the previous one, in the next whole second, computes elapsed = 1 and refills a full second’s worth, 5 tokens, for 1 ms of real time. Over a busy minute the bucket refills far faster than the rate allows and the effective limit drifts upward by tens of per cent.

Switch ts to milliseconds and the same sequence refills 0.005 tokens — correct. Second-resolution timestamps are the single most common precision bug in hand-rolled limiters, and they always fail in the client’s favour, so they are discovered during a capacity incident rather than a test.

Refill computed from second and millisecond timestamps With whole-second timestamps a request one millisecond after a second boundary refills a full second of tokens, while millisecond timestamps refill only the elapsed fraction, keeping the effective rate at the configured value. 1 ms of real time, two views of elapsed ts in whole seconds elapsed reported as 1 s refills 5 tokens effective rate drifts upward error always favours the client ts in milliseconds elapsed reported as 1 ms refills 0.005 tokens rate matches the contract the only correct resolution resolution must be finer than the emission interval, or every boundary leaks capacity

Clock source reference

Source Monotonic Resolution Survives restart Right for
redis.call('TIME') Within a node Microseconds Yes (server-side) Distributed limiters: one clock for the fleet
Wall clock (Date.now, time.time) No Milliseconds Yes Persisted state that must survive process restarts
Monotonic (performance.now, time.monotonic) Yes Sub-millisecond No — resets per process In-process buckets, client SDKs
Container/VM clock after resume No — can jump Milliseconds Yes Nothing, without clamping
Database NOW() No Microseconds Yes Limiters already colocated with a transaction

The rule that resolves most arguments: shared state must use the store’s clock; in-process state should use a monotonic clock. Mixing the two — refilling with a monotonic reading and persisting a wall-clock timestamp — produces state that is nonsense the moment the process restarts.

Step-by-step: a precise refill

lua
-- Precise refill: integer ms, clamped elapsed, capped capacity, epsilon compare.
local EPS = 1e-9
local cap  = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])          -- tokens per second
local cost = tonumber(ARGV[3]) or 1

-- One clock for every caller, in milliseconds.
local t   = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local st     = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(st[1]) or cap
local ts     = tonumber(st[2]) or now

-- Clamp: a clock correction must never produce a negative refill.
local elapsed = math.max(0, now - ts)
tokens = math.min(cap, tokens + elapsed / 1000 * rate)

local allowed = 0
if tokens + EPS >= cost then            -- epsilon: 0.9999999999 is one token
  tokens  = tokens - cost
  allowed = 1
end

-- Round to 3 dp so repeated float writes cannot accumulate drift.
tokens = math.floor(tokens * 1000 + 0.5) / 1000

-- Write the timestamp on BOTH paths, or the next refill measures from stale time.
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate * 1000) + 1000)

local retry = 0
if allowed == 0 then retry = math.ceil((cost - tokens) / rate * 1000) end
return { allowed, math.floor(tokens), retry }
What a backwards clock step does to an unclamped refill An unclamped limiter computes negative elapsed time after a clock correction, subtracts tokens, and locks the client out until the clock catches up; clamping elapsed at zero leaves the bucket untouched. NTP steps the clock back five seconds unclamped elapsed = −5000 ms tokens go negative client locked out for 5 s looks like a random outage clamped at zero elapsed = 0 tokens unchanged no refill, no penalty one max() call the cost of the fix is one function call; the cost of the bug is an unexplained lockout

Gotchas and edge cases

  • Mixing clocks between read and write. Refilling with a monotonic reading and storing a wall-clock timestamp produces an elapsed value that is meaningless. Pick one source per state.
  • Passing the application’s clock into a shared script. Every node’s skew is written into shared state; read the store’s clock instead, as the clock skew page covers in detail.
  • Not writing the timestamp on denial. The next refill then measures from an older instant and grants a larger refill than elapsed time justifies.
  • Unbounded float precision. Repeatedly storing 0.30000000000000004 and reading it back accumulates noise and makes tests platform-dependent. Round to a fixed precision.
  • Refill capped after the comparison. Capping tokens after checking tokens >= cost lets a long idle period admit more than capacity in one burst.
  • Monotonic clocks across process restarts. performance.now() restarts at zero, so persisted state computed from it produces enormous elapsed values on the next boot. Never persist monotonic readings.
  • Leap seconds and container suspend. Both produce jumps in wall time. Clamping handles backwards jumps; forward jumps simply refill to capacity, which is harmless.
Pairing each kind of state with the right clock State shared across processes must use the store's clock, in-process state should use a monotonic clock, and persisted state needs a wall clock; mixing a monotonic reading with persisted state produces meaningless elapsed times. Match the clock to where the state lives shared state store clock one clock, every node no skew possible in-process state monotonic clock immune to time changes resets with the process mixed sources monotonic read, wall-clock write elapsed is meaningless whichever you choose, read and write must use the same one

Verification and testing

python
# refill_test.py — assert precision across resolutions and clock anomalies.
import math

def refill(tokens, ts, now, cap, rate):
    elapsed = max(0, now - ts)                       # clamp
    return min(cap, tokens + elapsed / 1000 * rate)  # cap after refill

CAP, RATE = 5, 5           # 5 tokens, 5 per second

# 1. A millisecond past a boundary refills a millisecond's worth, not a second's.
assert abs(refill(0, 1_000, 1_001, CAP, RATE) - 0.005) < 1e-9

# 2. A long idle period refills to exactly capacity, never beyond.
assert refill(0, 0, 3_600_000, CAP, RATE) == CAP

# 3. A backwards clock step leaves the bucket untouched.
assert refill(2.5, 10_000, 5_000, CAP, RATE) == 2.5

# 4. Accumulated drift over a million operations stays negligible.
tokens, ts = float(CAP), 0
for i in range(1, 1_000_001):
    now = i * 7                                       # a request every 7 ms
    tokens = refill(tokens, ts, now, CAP, RATE); ts = now
    if tokens >= 1: tokens -= 1
expected_max = CAP
assert tokens <= expected_max + 1e-6, tokens
print("refill precision OK")

Add the same four assertions to whatever language your limiter is written in. They take minutes to write and cover the entire class of precision bug that otherwise surfaces as “our limit is 20% looser than we documented.”

Frequently Asked Questions

Milliseconds or microseconds for the timestamp?

Milliseconds are sufficient whenever the emission interval is at least a few milliseconds, which covers essentially every API rate limit. Microseconds add precision you cannot use and push integers closer to language-specific limits. What matters is that the resolution is finer than the interval between two admissible requests.

Should the limiter use a monotonic clock?

For in-process buckets, yes — it is immune to system time changes. For state shared across processes, no: monotonic readings are not comparable between machines and reset on restart. Use the store's own clock for shared state and a monotonic clock for local state, and never mix them.

Does float accumulation actually matter?

Rarely in magnitude, often in reproducibility. The drift over millions of operations stays far below one token, but comparisons like tokens >= 1 can fail at 0.9999999999, producing a rejection that no test explains. Compare with an epsilon or round to a fixed precision.

Why write the timestamp when a request is denied?

Because the timestamp records when the bucket state was last computed, not when a request succeeded. Skipping the write leaves an older instant in place, so the following refill credits time that has already been accounted for and the bucket grows faster than the configured rate.