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

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.

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.

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 express-rate-limit-redis-sliding ioredis

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

Older 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 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':

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:

  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.

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