fastify-sliding-limiter
v0.1.0
Published
Exact sliding-window rate limiting for Fastify, backed by Redis sorted sets. Multiple limits per route evaluated atomically in one round trip. Zero runtime dependencies.
Maintainers
Readme
fastify-sliding-limiter
Exact sliding-window rate limiting for Fastify, 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 fastify-sliding-limiter ioredisioredis, redis (node-redis) and fastify are peer dependencies — the
package itself installs nothing. Node 18+, Fastify 4 or 5.
Quickstart
import Fastify from 'fastify';
import IORedis from 'ioredis';
import slidingLimiter from 'fastify-sliding-limiter';
const app = Fastify();
const redis = new IORedis();
await app.register(slidingLimiter, { redis, limit: 100, windowMs: 60_000 });
app.get('/', async (request) => ({ remaining: request.rateLimit?.remaining }));
await app.listen({ port: 3000 });node-redis works the same way — pass a connected client:
import { createClient } from 'redis';
const redis = createClient();
await redis.connect();
await app.register(slidingLimiter, { redis, limit: 100, windowMs: 60_000 });The plugin sets Symbol.for('skip-override'), exactly as fastify-plugin
would, so its hook applies to the scope you register it in — no extra
dependency involved. Register it on the root instance for a global limit, or
inside an encapsulated scope to cover only that subtree.
Per-route configuration
Routes override the plugin defaults through config.rateLimit, the same
convention @fastify/rate-limit uses:
await app.register(slidingLimiter, { redis, limit: 100, windowMs: 60_000 });
// Exempt entirely.
app.get('/health', { config: { rateLimit: false } }, healthHandler);
// Tighter limit, on a bucket of its own.
app.post('/login', { config: { rateLimit: { limit: 5, windowMs: 300_000 } } }, loginHandler);
// Same limits as the plugin default, different rejection.
app.get(
'/search',
{ config: { rateLimit: { statusCode: 418, message: { error: 'slow down' } } } },
searchHandler,
);A route that reshapes its limits gets its own key namespace
({prefix}:{METHOD:url}) so two routes never share a bucket by accident. Set
prefix in the override to choose one yourself — that is also how you make
several routes share a budget deliberately.
Overrides are resolved by an onRoute hook, so a malformed one throws where the
route is declared rather than on the first request that hits it.
Several limits at once
A burst ceiling and a sustained budget are different questions, and answering them with two plugins 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:
await app.register(slidingLimiter, {
redis,
limits: [
{ name: 'burst', limit: 10, windowMs: 1_000 },
{ name: 'sustained', limit: 100, windowMs: 60_000 },
],
});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 plugin 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
Everything below can be set on the plugin; everything except the connection itself can also be overridden per route.
| 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 | (request, reply) => string | masked client IP | See the IPv6 note below |
| skip | (request, reply) => boolean | — | Bypass the limiter entirely |
| cost | number \| (request, reply) => 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 | (request, reply, result) => unknown | — | Takes over the rejection; return a payload or send the reply yourself |
| 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 request that reached the limiter gets the full result on
request.rateLimit (allowed, policies[], binding, remaining,
resetMs, retryAfterMs, …), typed through Fastify's module augmentation.
The limiter on its own
The plugin 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 'fastify-sliding-limiter/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':
app.post('/login', {
config: {
rateLimit: {
limit: 5,
windowMs: 300_000,
onError: 'deny', // answer 503 instead of letting the request through
onStoreError: (error) => app.log.error({ error }, 'rate limiter unavailable'),
},
},
}, loginHandler);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 'fastify-sliding-limiter';
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, build the instance with
Fastify({ trustProxy: true }) so request.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.
Express
Same engine, separate package:
express-rate-limit-redis-sliding.
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
