10LOC
Node.jsadvanced

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

  • tokens and lastRefill are captured in the closure returned by createTokenBucket — 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 times refillPerSecond, added to the current balance and capped at capacity so idle time doesn't let the bucket overfill.
  • lastRefill is updated to now on every call — including calls that end up returning false — 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 to 1); if so, it deducts and returns true, 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 tokens in 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 another performance.now() reading, never to a stored timestamp from a previous process run.
  • A bucket per client (per API key, per IP) needs a Map of 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.