A distributed, Redis-backed token bucket rate limiter for Express. Atomic per-request decisions are made inside Redis via a Lua script, so it stays correct across multiple Node processes and machines.
npm install token-bucket-limiterPeer requirements:
- Node.js 18+
- Express 5
- A reachable Redis instance (currently
redis://127.0.0.1:6379)
import express from "express";
import { tokenBucketLimiter } from "token-bucket-limiter";
const app = express();
app.post(
"/login",
tokenBucketLimiter({ capacity: 10, refillRate: 5 }),
(req, res) => {
res.json({ success: true });
}
);
app.listen(3000);That route now allows bursts of up to 10 requests and sustains 5 requests/second per caller.
| Option | Type | Default | Description |
|---|---|---|---|
capacity |
number |
10 |
Maximum tokens the bucket can hold. Controls the largest burst you allow. |
refillRate |
number |
5 |
Tokens added per second. Controls the long-term steady-state request rate. |
Rule of thumb:
- "Maximum spike I'll allow?" →
capacity - "Maximum steady traffic?" →
refillRate
Both values must be > 0 or the middleware throws on first use.
The limiter picks a key from the request, in this order:
x-api-keyheader →tb:api:<value>x-user-idheader →tb:user:<value>- Express
req.ipfallback →tb:ip:<ip>
If you rely on the IP fallback behind a proxy, enable Express's trust proxy setting so req.ip reflects the real client:
app.set("trust proxy", true);Every response carries:
X-RateLimit-Remaining— floor of remaining tokens in the bucket.
When the bucket is empty, the middleware short-circuits with:
- HTTP
429 Too Many Requests Retry-Afterheader (seconds until at least one token is available)- JSON body:
{ "error": "Too many requests" }
Each key maps to a Redis hash storing tokens and last (timestamp of last refill). On every request the Lua script:
- Loads the current state (or seeds a full bucket on first use).
- Refills
elapsed_ms * (refillRate / 1000)tokens, capped atcapacity. - If at least one token is available, decrements by one and marks the request
allowed. - Persists
tokens+lastand sets a TTL of2 × (capacity / refillRate)seconds so idle buckets are eventually evicted.
Because the read-modify-write happens inside a single Lua call, the decision is atomic — no race between concurrent Node workers.
ISC