Weighted Token-Bucket Rate Limiting for Node.js & Bun
Rate limiting is one of those things every API needs and almost every implementation gets subtly wrong. The classic approaches have real blind spots:
- Fixed-window counters ("max 100 requests/minute") are simple but bursty — a client can fire 100 requests at 11:59:59 and another 100 at 12:00:00.
-
Request-counting middlewares like
express-rate-limitcount requests, not weight. A cheap status check costs the same as a heavy report query, and giving power users a higher cap means forking your code.
The token bucket solves both. Tokens refill continuously at a configurable rate, and each request costs a configurable weight — so a few heavy requests can share a budget with many light ones. And when you store the bucket in Redis with an atomic Lua script, the same limit holds across every instance of a horizontally scaled service, with no read-modify-write race.
The core idea
request ──► key (IP / user / route)
token bucket (capacity, refillRate)
tokens = min(capacity, tokens + elapsed_time × rate)
if tokens >= cost ──► allow, tokens -= cost
else ─────────────► 429 + Retry-After
Two backends:
- MemoryBackend — in-process, zero dependencies, perfect for a single instance.
- RedisBackend — shared across instances, updated by one atomic Lua script so distributed limits stay correct.
A minimal example
import { RateLimiter } from "rate-limiter-middleware";
const limiter = new RateLimiter({
capacity: 100, // burst allowance
refillRate: 10, // tokens per second
});
// Framework-agnostic handle() — works with fetch, Hono, bare http, etc.
const decision = await limiter.handle(req, res, {
keyGenerator: (req) => req.headers["x-api-key"] ?? req.socket.remoteAddress,
});
if (!decision.allowed) {
res.statusCode = 429;
res.setHeader("Retry-After", decision.retryAfter);
res.end("Too Many Requests");
}
Or drop it in as Express-style middleware:
import { rateLimitMiddleware } from "rate-limiter-middleware";
app.use("/api", rateLimitMiddleware({ capacity: 100, refillRate: 10 }));
Per-path weights
Heavy endpoints get their own budget without forking code:
new RateLimiter({
routes: {
"/api/search": { capacity: 30, refillRate: 5 },
"/api/export": { capacity: 5, refillRate: 1 },
},
});
Going distributed
import { RedisBackend } from "rate-limiter-middleware/redis";
const limiter = new RateLimiter({
backend: new RedisBackend({ url: process.env.REDIS_URL }),
capacity: 100,
refillRate: 10,
});
Now every instance of your service shares one bucket. If Redis goes down, the limiter fails open — it degrades gracefully instead of blocking all traffic.
Why this matters
Lazy refill means no timers — an idle instance costs nothing. Every decision emits an event (key, allowed, remaining, retry-after) so you can wire it into your metrics. Weighted costs let you price heavy endpoints fairly. And the atomic Lua script is the difference between "rate limiter" and "rate limiter that's actually correct under load."
The library is rate-limiter-middleware — MIT-licensed, strictly typed, one runtime dependency (ioredis, only when you use the Redis backend). If you've been fighting bursty fixed windows or counting requests instead of weight, it's worth a look.
Top comments (0)