Distributed rate limiting for Express, using a token bucket over Redis or memory.
One request, distributed over Redis, traced module by module: trace it yourself through any of the six real flows, or read architecture.md for why it is built this way and what breaks at each scale.
Every client holds a bucket of tokens. Tokens accrue continuously at requestsPerMinute / 60 per second and are capped at capacity, which is what makes bursts possible. A request costs one token; no token, no request.
1. Request hits the middleware
2. Resolve the client identifier (IP, API key, ...)
3. Read the bucket for (scope, identifier)
4. Add the tokens accrued since the last read
5. Try to spend one
6. Success -> next() Failure -> 429 with Retry-After
Nothing runs on a timer: the bucket is a token count plus the timestamp it was last touched, so its current state is always derivable on read.
Every server pointed at the same Redis shares one bucket per (scope, identifier), so limits hold across the fleet rather than per process. The whole read-refill-consume cycle runs inside one Lua script, so concurrent servers cannot double-spend a token, and the script reads the clock from Redis rather than from the caller. Buckets are keyed ratelimit:<scope>|<identifier>, one bucket per limiter rather than one per client, with a TTL derived from each bucket's refill time. The reasoning behind each of those.
Pass rootNodes instead of host/port/url and RedisStore switches to redis.createCluster:
const store = new RedisStore({
rootNodes: [
{ url: 'redis://10.0.0.1:6379' },
{ url: 'redis://10.0.0.2:6379' },
{ url: 'redis://10.0.0.3:6379' }
]
});checkLimit is slot-safe for free: every call touches exactly one key, so the cluster client routes it to the right shard with no extra work. Script loading, getStats(), resetAll() and multi-key DEL all had to be built to fan out across masters, and a single master going down degrades only the slots it owns. What cluster mode changes, and what it costs.
Covered by tests/redisCluster.test.js against a live 3-node cluster; see Testing for how to point the suite at one.
A rate limiter sits in front of every request, so its failure mode is the service's failure mode. This one is explicit:
- Commands are not queued while the socket is down, so they fail immediately instead of piling up.
- Every check is raced against
commandTimeoutMs(default 250ms). - Connection attempts are rate limited to one per
reconnectCooldownMs, so a dead Redis costs one attempt per second rather than one per request. failOpendecides what happens next:true(default) allows the request,falserejects it withRetry-After: 1. Both paths are counted instore.metrics(degradedAllowed/degradedDenied), and repeated errors are logged once per window instead of once per request./healthreports 503 while Redis is unreachable, so a load balancer can route around the instance.
Measured on this repo with Redis killed mid-traffic: requests complete in 1-2ms on the degraded path, and enforcement resumes on its own once Redis returns. Choosing between fail-open and fail-closed, and the full failure table.
$ BENCH_REQUESTS=3000 npm run bench # node v22.18.0, macOS, local Redis
configuration rps p50 ms p95 ms p99 ms added p50
no rate limiting 1487 0.50 0.84 2.04 +0.00 ms
MemoryStore 1596 0.53 0.89 2.46 +0.02 ms
RedisStore 1039 0.89 1.39 3.25 +0.38 ms
Reproduce with npm run bench; the harness is tests/bench.js. Read these as a delta, not a service level: it is loopback traffic, the handler does no work, and the load generator shares the event loop with the server (which is why concurrency defaults to 1 - it is the only setting where the numbers isolate per-request cost). On a real handler the limiter's share is proportionally much smaller.
git clone <your-fork-url> rate-limiter
cd rate-limiter
npm install
npm start
# 5 allowed, then 429
for i in $(seq 1 7); do curl -s -o /dev/null -w '%{http_code} ' http://127.0.0.1:3000/api/expensive; donebrew install redis # macOS
# sudo apt-get install redis-server # Debian/Ubuntu
redis-server &
ADMIN_TOKEN=$(openssl rand -hex 24) npm run start:redis
# second instance, same Redis, shared limits
PORT=3001 SERVER_ID=server2 npm run start:redis
curl http://127.0.0.1:3000/api/expensive
curl http://127.0.0.1:3001/api/expensive # spends from the same bucketRequires Node 18.17 or newer.
const rateLimiter = require('rate-limiter');
// Aggregate net. Mounted before the routes, or it only ever runs on 404s.
app.use(rateLimiter({ requestsPerMinute: 300, scope: 'global' }));
// Per-route limit. A request passing through both limiters spends a token in
// each, so keep the aggregate looser than anything it sits above.
app.post('/api/expensive', rateLimiter({ requestsPerMinute: 5, scope: 'expensive' }), handler);Limiters that do not bring their own store share one in-memory store, so ten limiters cost one cleanup timer rather than ten.
const { createRateLimiter, RedisStore } = require('rate-limiter');
const store = new RedisStore({
url: process.env.REDIS_URL, // or host/port/password
failOpen: true, // allow traffic if Redis is down
commandTimeoutMs: 250
});
await store.connect(); // optional; the first request connects too
app.use(createRateLimiter({ requestsPerMinute: 100, scope: 'global', store }));
// on SIGTERM
await store.close();const known = new Set(await loadApiKeys());
app.use(rateLimiter({
requestsPerMinute: 10,
scope: 'api-key',
// Validate before trusting: an unvalidated header means a client can mint a
// fresh bucket per request just by changing it.
identifier: (req) => {
const key = req.headers['x-api-key'];
return known.has(key) ? `key:${key}` : req.ip;
}
}));req.ip is the socket address unless Express is told otherwise, so behind a proxy every client collapses into the proxy's bucket:
app.set('trust proxy', 1); // number of proxies you actually controlOnly do this if the proxy overwrites X-Forwarded-For rather than appending to a client-supplied value, otherwise the header becomes a free bypass. The middleware logs a warning once if it sees X-Forwarded-For while trust proxy is unset. Both failure modes, in detail.
| Option | Type | Default | Description |
|---|---|---|---|
requestsPerMinute |
number | 60 |
Sustained rate. 0 rejects everything. |
capacity |
number | requestsPerMinute |
Burst size (max tokens held). |
cost |
number | 1 |
Tokens spent per request. Must not exceed capacity. |
scope |
string | hash of the limits | Bucket namespace. Set it explicitly for readable Redis keys. |
store |
object | shared MemoryStore |
MemoryStore or RedisStore. |
identifier |
function | (req) => req.ip |
Client identity. Return a falsy value to skip. |
skip |
function | () => false |
Bypass condition. |
handler |
function | null |
Custom rejection response. |
headers |
boolean | true |
Emit the RateLimit-* / X-RateLimit-* headers. |
message |
string | "Too many requests..." |
Message in the default 429 body. |
statusCode |
number | 429 |
Rejection status. |
logger |
object | console |
Anything with warn/error/log. |
Invalid configuration throws at wire-up rather than misbehaving per request: NaN, negative values, a cost above capacity, a malformed scope, or a store missing checkLimit are all rejected immediately.
| Option | Default | Description |
|---|---|---|
url / host / port |
127.0.0.1:6379 |
url wins when both are given. Ignored in cluster mode. |
rootNodes |
unset | Array of { url } or { host, port }. Presence switches to Redis Cluster mode (redis.createCluster); see Redis Cluster. |
username / password / db |
- | Passed through to node-redis. db is not supported in cluster mode (Redis Cluster is always db 0). |
socket |
{} |
Merged into the socket options (TLS, etc.). |
keyPrefix |
ratelimit: |
Key namespace. |
failOpen |
true |
Allow (true) or reject (false) when Redis is unusable. |
commandTimeoutMs |
250 |
Per-request budget for the Lua call. |
connectTimeoutMs |
1000 |
Connect / SCRIPT LOAD budget. |
reconnectCooldownMs |
1000 |
Minimum gap between connect attempts. |
minTtlSeconds / maxTtlSeconds / ttlPaddingSeconds |
60 / 86400 / 60 |
Bounds for the derived key TTL. |
degradedRetryAfterMs |
1000 |
Retry-After used when failing closed. |
logIntervalMs |
5000 |
Error-log throttle window. |
| Option | Default | Description |
|---|---|---|
maxClients |
100000 |
Hard bound on tracked clients (LRU eviction beyond it). |
cleanupIntervalMs |
600000 |
Sweep period. The timer is unref()d. |
inactiveAfterMs |
3600000 |
Idle age at which a bucket is dropped. |
autoCleanup |
true |
Set false to sweep manually. |
| Header | Meaning |
|---|---|
RateLimit-Limit / X-RateLimit-Limit |
Bucket capacity in force |
RateLimit-Remaining / X-RateLimit-Remaining |
Whole tokens left |
RateLimit-Reset |
Seconds until the bucket is back at capacity |
X-RateLimit-Reset |
The same instant as an ISO-8601 timestamp |
Retry-After |
Seconds until the next token, on rejection only. Never 0, and omitted entirely when waiting cannot help (a limiter that never refills). |
Both storage backends produce identical values for the same state.
They only exist when ADMIN_TOKEN is set (16+ characters), and they require a bearer token compared in constant time. reset clears a client's limit and stats enumerates buckets, so leaving either open defeats the limiter.
export ADMIN_TOKEN=$(openssl rand -hex 24)
# counts and per-scope cardinality; identifiers are never returned in the clear
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:3000/api/admin/stats
# hashed identifiers, if you need to correlate without exposing API keys
curl -H "Authorization: Bearer $ADMIN_TOKEN" 'http://127.0.0.1:3000/api/admin/stats?identifiers=hashed'
# clear one scope, or every scope for that client
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
'http://127.0.0.1:3000/api/admin/reset/1.2.3.4?scope=expensive'
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
'http://127.0.0.1:3000/api/admin/bucket/1.2.3.4?scope=expensive'GET /health needs no token and reports Redis reachability, latency, and the failOpen setting.
npm test # unit + integration
TEST_REDIS_URL=redis://127.0.0.1:6379 npm test # + the Redis suite
TEST_REDIS_CLUSTER_NODES=127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002 npm test # + the cluster suite
npm run bench # latency harness54 tests over the bucket arithmetic, both storage backends, and the middleware. The Redis- and cluster-backed tests skip themselves when their target isn't reachable rather than failing. Covered explicitly, because each one was once broken:
- exhausting a strict endpoint must not affect a relaxed one
- rotating an unrecognised API key must not mint quota
requestsPerMinute: 0must reject without advertising an impossible retry- a throwing
identifier/skip/store must become a 500, never a hung request - an unreachable Redis must answer in bounded time, both fail-open and fail-closed
- waiting exactly
Retry-Aftermust be enough to succeed - a clock moving backwards must neither mint nor destroy tokens
- stats must never return identifiers in the clear
- on a cluster, a downed master must only degrade the slots it owns
- on a cluster,
EVALSHAmust notNOSCRIPTon a master the client didn't happen to route to first
tests/tests.txt is the manual curl runbook, including the multi-server checks.
| Variable | Default | Applies to |
|---|---|---|
PORT |
3000 |
both |
ADMIN_TOKEN |
unset | both - admin routes and the bypass are disabled without it |
API_KEYS |
demo-key-1,demo-key-2 |
both - comma-separated allowlist |
LOG_CLIENT_IPS |
unset | both - log raw IPs instead of hashes |
REDIS_URL / REDIS_HOST / REDIS_PORT / REDIS_PASSWORD |
127.0.0.1:6379 |
Redis demo |
FAIL_OPEN |
1 |
Redis demo - 0 rejects while Redis is down |
REQUIRE_REDIS |
unset | Redis demo - 1 refuses to start without Redis |
SERVER_ID |
primary |
Redis demo - identifies the instance in responses |
Both demos drain on SIGTERM and SIGINT: the listener stops, in-flight requests get 10 seconds, then Redis is closed and the process exits 0.
- Atomicity is the easy part. The hard parts are the key design around the script, whose clock the arithmetic trusts, and what happens when the dependency is gone.
- A limiter's error path is a security control: fail-open during an outage means no limits at all, so it has to be a deliberate, visible choice.
- Any identity a client can choose is not an identity. Validate it, or bound the damage.
- Admin endpoints on a rate limiter are the rate limiter. Unauthenticated
resetis a bypass, and unauthenticatedstatsleaks whatever you used as an identifier.
All four are worked through, with the code that implements them, in architecture.md.
MIT - see LICENSE.
