A token-bucket rate limiter in 10 lines
Published April 2, 2027
export const createTokenBucket = (capacity: number, refillPerSecond: number) => {
let tokens = capacity;
let lastRefill = performance.now();
return (cost = 1): boolean => {
const now = performance.now();
tokens = Math.min(capacity, tokens + ((now - lastRefill) / 1000) * refillPerSecond);
lastRefill = now;
if (tokens < cost) return false;
tokens -= cost;
return true;
};
};What
createTokenBucket(capacity, refillPerSecond) returns a function you call once per request: it returns true and deducts a token if one's available, false if the bucket is empty. Tokens refill continuously at refillPerSecond, calculated lazily on each call rather than ticked by a background timer.
Why it matters
A fixed-window limiter ("100 requests per minute") is simple but bursty at the edges: 100 requests in the last second of one window plus 100 more in the first second of the next window slips 200 requests through in two real seconds. A token bucket avoids that by tracking a continuously-refilling budget instead of resetting a counter on a clock boundary — burst up to capacity at once, then throttle to the steady refillPerSecond rate.
The naive implementation runs a setInterval that adds tokens on a tick. That works, but it means the bucket is doing work (and holding the event loop open) even while nobody's calling it, and its accuracy is capped by how fine-grained that interval is. Computing the refill lazily — "how much time passed since I last checked, times the refill rate" — is exact regardless of how long the bucket sits idle between calls, and costs nothing when it's not being used.
How it works
tokensandlastRefillare captured in the closure returned bycreateTokenBucket— each call to the factory produces an independent bucket with its own state.- Every call to the returned function first computes how many tokens should have accumulated since
lastRefill: elapsed seconds timesrefillPerSecond, added to the current balance and capped atcapacityso idle time doesn't let the bucket overfill. lastRefillis updated tonowon every call — including calls that end up returningfalse— so the elapsed-time calculation is always measured from the most recent check, not from when the bucket was created.- Only after the refill is applied does it check whether there's enough balance for
cost(defaulting to1); if so, it deducts and returnstrue, otherwise it declines without touching the balance.
Gotchas
- This implementation isn't safe to share across concurrent requests without a lock — two calls racing between reading and writing
tokensin a multi-threaded context could both see a stale balance. In single-threaded Node this is a non-issue for synchronous calls like this one; it becomes relevant again if you port the same logic to a shared store (Redis, etc.) for a multi-process deployment. performance.now()is relative to process start, not wall-clock time — fine here since it's only ever compared to anotherperformance.now()reading, never to a stored timestamp from a previous process run.- A bucket per client (per API key, per IP) needs a
Mapof buckets, not one shared bucket — one global bucket rate-limits your whole service as a single unit, not each caller independently.
Related
- Fixed-window / sliding-window counters — simpler to reason about and implement, but allow the boundary-burst behavior a token bucket avoids.
retryWithBackoff— the client-side complement: a token bucket decides whether to let a request through server-side; backoff decides how a client should react when it's told no.