npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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.

npm license

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 ioredis

ioredis, 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:      3

Older 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 tier

consume(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:

  1. ZREMRANGEBYSCORE drops the tail that slid out of the window, then ZCARD counts what is left.
  2. If every tier has room, ZADD records cost members scored now and PEXPIRE renews the key's lifetime. Otherwise nothing is written.
  3. ZRANGE key -1 -1 gives the newest member for resetMs; when a tier is violated, the nth oldest member gives the exact retryAfterMs.

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:exports

The 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