Tier 4 — Advanced

Rate Limiter (Token Bucket)

Build a rate limiter that allows N requests per time window — the token bucket algorithm that protects every real API.

🐍 Python 🟨 JavaScript

1. The Problem

Build a rate limiter using the token-bucket algorithm. Each user gets a bucket of tokens (the capacity). Every request spends one token; tokens refill over time at a fixed rate. If the bucket is empty, the request is rejected.

Why this project? Every real API has a rate limiter. Token bucket is the standard because it allows short bursts up to capacity while enforcing a steady long-run rate — and it needs careful time-based reasoning.

2. How to Think About This

For each user store two numbers: how many tokens they have and when we last checked. On each request, first refill by elapsed × refillRate tokens, capped at capacity. Then, if there is at least one token, spend it and allow; otherwise reject. Passing now in as an argument (instead of reading the clock) makes it deterministic and testable.

3. The Build

limiter.mjs
export class RateLimiter {
  constructor(capacity, refillPerSecond) {
    this.capacity = capacity;          // burst size
    this.refillRate = refillPerSecond; // tokens per second
    this.buckets = new Map();          // user -> { tokens, last }
  }

  allow(user, now = Date.now() / 1000) {
    const b = this.buckets.get(user) || { tokens: this.capacity, last: now };
    const elapsed = now - b.last;
    let tokens = Math.min(this.capacity, b.tokens + elapsed * this.refillRate);

    if (tokens >= 1) {
      this.buckets.set(user, { tokens: tokens - 1, last: now });  // spend one
      return true;
    }
    this.buckets.set(user, { tokens, last: now });                // none to spend
    return false;
  }
}

// 3 requests allowed, refilling 1 per second.
const limiter = new RateLimiter(3, 1);
for (let i = 1; i <= 5; i++) {
  console.log(`Request ${i}: ${limiter.allow("alice", 0) ? "allowed" : "REJECTED"}`);
}

4. Test & Prove

limiter.test.mjs
import { RateLimiter } from "./limiter.mjs";

let pass = 0, fail = 0;
function test(desc, fn) {
  try { fn(); console.log("  \u2713", desc); pass++; }
  catch (e) { console.log("  \u2717", desc, "\u2014", e.message); fail++; }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || "assertion failed"); }

test("the first 3 requests (capacity 3) are allowed", () => {
  const l = new RateLimiter(3, 1);
  assert(l.allow("u", 0) && l.allow("u", 0) && l.allow("u", 0));
});
test("the 4th request at the same instant is rejected", () => {
  const l = new RateLimiter(3, 1);
  for (let i = 0; i < 3; i++) l.allow("u", 0);
  assert(l.allow("u", 0) === false);
});
test("tokens refill over time", () => {
  const l = new RateLimiter(3, 1);
  for (let i = 0; i < 3; i++) l.allow("u", 0);
  assert(l.allow("u", 0) === false);   // empty
  assert(l.allow("u", 2) === true);    // 2s later, refilled
});
test("different users have independent buckets", () => {
  const l = new RateLimiter(1, 1);
  assert(l.allow("alice", 0) === true);
  assert(l.allow("bob", 0) === true);
  assert(l.allow("alice", 0) === false);
});

console.log(`\n${pass} passed, ${fail} failed`);

5. The Interface

node limiter.mjs

Request 1: allowed
Request 2: allowed
Request 3: allowed
Request 4: REJECTED
Request 5: REJECTED

6. Run It

node limiter.mjs
node limiter.test.mjs

🎯 Try this next

  1. Add a retry-after value telling the caller how long until a token frees up
  2. Support per-endpoint limits (a Map of endpoint to its own limiter)
  3. Add a global limiter on top of per-user limits
  4. Switch to a sliding-window-log algorithm and compare the burst behaviour

What you practised

The token-bucket algorithm, time-based state that refills lazily on access, capping with Math.min, and making time-dependent code deterministic by injecting now.

Reference: Maps · Numbers & Math · Functions

Try It Yourself