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
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
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
- Add a retry-after value telling the caller how long until a token frees up
- Support per-endpoint limits (a Map of endpoint to its own limiter)
- Add a global limiter on top of per-user limits
- 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