express-rate-limit-redis-sliding
v0.1.1
Published
Exact sliding-window rate limiting for Express, backed by Redis sorted sets. Multiple limits per route evaluated atomically in one round trip. Zero runtime dependencies.
Maintainers
Readme
express-rate-limit-redis-sliding
Exact sliding-window rate limiting for Express, backed by Redis sorted sets. Multiple limits per route, evaluated atomically in a single round trip. Zero runtime dependencies.
The problem with fixed windows
Most simple rate limiters count requests into a bucket keyed by
floor(now / windowMs). The bucket resets the instant it rolls over, so a
client that waits for the boundary gets two full quotas back to back:
limit: 100 requests per minute
00:59.999 ├─ 100 requests ─┤ bucket "minute 0" is full
01:00.000 ├─ 100 requests ─┤ bucket "minute 1" is empty again
└─ 200 requests in 2 milliseconds ─┘A sliding window counts the last windowMs from now, so the boundary does not
exist. This package keeps one sorted set entry per request unit, scored by
timestamp, and lets Redis drop the tail as it ages out. That is exact — no
approximation, no leaky-bucket smoothing.
The sliding-vs-fixed test pins the
difference: five requests just before the boundary and five just after are all
served by a fixed window and all rejected here.
Install
npm install express-rate-limit-redis-sliding ioredisioredis, redis (node-redis) and express are peer dependencies — the
package itself installs nothing. Node 18+.
Quickstart
import express from 'express';
import IORedis from 'ioredis';
import { rateLimit } from 'express-rate-limit-redis-sliding';
const app = express();
const redis = new IORedis();
app.use(rateLimit({ redis, limit: 100, windowMs: 60_000 }));
app.get('/', (req, res) => {
res.json({ remaining: req.rateLimit?.remaining });
});node-redis works the same way — pass a connected client:
import { createClient } from 'redis';
const redis = createClient();
await redis.connect();
app.use(rateLimit({ redis, limit: 100, windowMs: 60_000 }));Several limits at once
A burst ceiling and a sustained budget are different questions, and answering them with two middlewares means two round trips and a subtle bug: the request the slow limiter rejects has already been charged to the fast one.
Declare them together and they are evaluated — and committed — in one atomic script:
app.post(
'/reports',
rateLimit({
redis,
limits: [
{ name: 'burst', limit: 10, windowMs: 1_000 },
{ name: 'sustained', limit: 100, windowMs: 60_000 },
],
}),
createReport,
);If burst has no room, sustained is left untouched. Retry-After reports the
longest wait among the tiers that actually blocked the request.
Response headers
Defaults to draft-8, the only format that can describe every tier in a single header:
RateLimit-Policy: "burst";q=10;w=1, "sustained";q=100;w=60
RateLimit: "burst";r=7;t=1, "sustained";r=64;t=42
Retry-After: 3Older formats carry one policy, so the middleware picks the tier that binds: a tier that actually rejected the request first, then the lowest remaining quota, then the one that takes longest to replenish.
| headers value | Emits |
| --- | --- |
| 'draft-8' (default) | RateLimit, RateLimit-Policy as structured field lists |
| 'draft-7' | RateLimit: limit=…, remaining=…, reset=… |
| 'draft-6' | RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset |
| 'legacy' | X-RateLimit-*, with an absolute unix reset timestamp |
| ['draft-8', 'legacy'] | Both, for a staged migration |
| false | Nothing, including Retry-After |
Options
| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| redis | client | — | ioredis, node-redis, or your own RedisAdapter |
| limiter | SlidingWindowLimiter | — | Reuse one instance instead of redis + limits |
| limit / windowMs | number | — | Shorthand for a single tier |
| limits | LimitSpec[] | — | { name?, limit, windowMs }, names default to t0, t1, … |
| prefix | string | 'srl' | Key namespace |
| keyGenerator | (req, res) => string | masked client IP | See the IPv6 note below |
| skip | (req, res) => boolean | — | Bypass the limiter entirely |
| cost | number \| (req, res) => number | 1 | Units this request spends |
| headers | see above | 'draft-8' | |
| statusCode | number | 429 | |
| message | string \| object \| fn | 'Too many requests…' | Strings go out as text, objects as JSON |
| handler | (req, res, next, result) => void | — | Takes over the rejection response |
| onError | 'allow' \| 'deny' \| fn | 'allow' | What to do when Redis is down |
| errorStatusCode | number | 503 | Used by onError: 'deny' |
| onStoreError | (error) => void | — | Observability hook for every Redis failure |
| clock | 'redis' \| 'local' | 'redis' | |
| timeProvider | () => number | Date.now | Only used by clock: 'local' |
Every allowed request gets the full result on req.rateLimit
(allowed, policies[], binding, remaining, resetMs, retryAfterMs, …).
The limiter on its own
The middleware is a thin wrapper. Reach for the core directly to limit websocket frames, queue jobs, or anything else that is not an HTTP request:
import { SlidingWindowLimiter } from 'express-rate-limit-redis-sliding/core';
const limiter = new SlidingWindowLimiter({
redis,
limits: [{ name: 'sms', limit: 5, windowMs: 3_600_000 }],
});
const result = await limiter.consume('user:42');
if (!result.allowed) throw new Error(`retry in ${result.retryAfterMs}ms`);
await limiter.peek('user:42'); // read the state, record nothing
await limiter.reset('user:42'); // clear every tierconsume(id, cost) spends several units at once — useful for weighting an
expensive endpoint. A cost larger than the smallest configured limit throws a
RangeError rather than blocking forever.
When Redis is down
onError defaults to 'allow': if the limiter cannot reach Redis, requests go
through unlimited rather than the API going down with it. That is the right
default for most services and the wrong one for some — a login endpoint
protecting against credential stuffing probably wants 'deny':
rateLimit({
redis,
limit: 5,
windowMs: 60_000,
onError: 'deny', // answer 503 instead of letting the request through
onStoreError: (error) => logger.error({ error }, 'rate limiter unavailable'),
});Clock source
By default now comes from Redis TIME, so every application instance shares
one timeline. This matters: with local clocks, a few seconds of drift between
instances silently widens or narrows the window depending on which instance a
request lands on.
clock: 'local' uses timeProvider instead (defaulting to Date.now), which
is how this package's own tests stay deterministic without sleeping.
Limiting by IP
The default keyGenerator masks IPv6 addresses to a /56 before using them
as a key. A single subscriber is routinely handed an entire /64, so limiting a
bare IPv6 address lets one client cycle through billions of them:
import { ipKeyGenerator } from 'express-rate-limit-redis-sliding';
ipKeyGenerator('2001:db8:1234:5678:9abc:def0:1234:5678'); // '2001:db8:1234:5600::/56'
ipKeyGenerator('203.0.113.7'); // '203.0.113.7'
ipKeyGenerator('2001:db8::1', { ipv6Subnet: 64 }); // '2001:db8::/64'If your app sits behind a proxy, set app.set('trust proxy', …) so req.ip is
the real client. Getting this wrong is the classic rate-limiter bypass: either
every request shares the load balancer's IP, or clients spoof
X-Forwarded-For freely.
Redis Cluster
Each tier is its own key, and the script is multi-key, so all of a client's keys
must live in one slot. Keys are built as
{prefix}:{{identifier}}:{tier} — the braces are a Redis Cluster hash tag, so
the slot is decided by the identifier alone. Braces inside an identifier are
replaced with _ so they cannot open a competing tag.
Cost
A sliding log is exact because it remembers every request. That costs
O(limit) memory per active client — a 1000/minute limit means up to 1000
sorted set entries per client, roughly 60–90 KB. Keys carry a PEXPIRE renewed
on every write, so an idle client's key disappears exactly when its window
drains; there is no cleanup job.
For very large limits, prefer a shorter window (600/6s instead of
6000/60s), or reach for an approximate counter.
How it works
One EVALSHA per request, EVAL only on NOSCRIPT (which covers Redis
restarts and cluster failover automatically). Per tier the script:
ZREMRANGEBYSCOREdrops the tail that slid out of the window, thenZCARDcounts what is left.- If every tier has room,
ZADDrecordscostmembers scorednowandPEXPIRErenews the key's lifetime. Otherwise nothing is written. ZRANGE key -1 -1gives the newest member forresetMs; when a tier is violated, thenth oldest member gives the exactretryAfterMs.
The reply is a flat array of integers, which ioredis and node-redis decode identically.
Fastify
Same engine, separate package:
fastify-sliding-limiter.
Development
npm install
npm run typecheck && npm run lint && npm run build
npm test # spawns a real redis-server on an ephemeral port
npm run check:exportsThe suite runs against a real Redis, not a mock — the value of this package is in the semantics of the Lua script, and a mock would only test itself.
License
MIT © Kayo Santos
