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

redis-sliding-rate-limiter

v7.0.2

Published

Flexible and performant rate limiter based on sliding window algorithm with arbitrary precision

Readme

redis-sliding-rate-limiter

Flexible and performant sliding-window rate limiter for Node.js, backed by Redis and exact to the microsecond. Works with both popular Redis clients: node-redis and ioredis.

node-redis ioredis

Table of Contents

Why another rate limiter?

Naive rate limiter algorithms suffer from a well known problem where a burst of requests at the window boundary can let through more requests than intended for a particular key. For example, with a limit of 100 requests per minute, a client could send 100 requests in the last second of the first minute and another 100 requests in the first second of the second minute, resulting in 200 accepted requests in 2 seconds. This library implements an exact sliding window on top of Redis that eliminates that boundary burst.

The main features are:

  • Exact sliding window at microsecond precision. The window slides continuously; there is no subdivision unit and no bucketing, so a request counts against exactly the trailing window that precedes it.
  • Redis server clock as the single source of truth. All timing is derived from the Redis server clock, so application clocks and network latency never affect the count.
  • Per-request expiration timestamps. Every response carries firstExpireAtMs (when the oldest member in the window expires) and windowExpireAtMs (when the window becomes empty), so a client knows precisely when a free slot opens up.
  • Express middleware with customizable status code, messages and response headers.
  • Works with both node-redis and ioredis (majors 5 and 6), via a duck-typed client or an explicit sendCommand callback.
  • Ships dual CJS and ESM builds with an exports map and bundled type declarations.
  • Zero runtime dependencies.

Installation

npm install --save redis-sliding-rate-limiter

redis and ioredis are peer libraries: install whichever client you use yourself. The limiter has no runtime dependencies of its own.

Compatibility

| Component | Supported | | -------------- | ----------------------------- | | Node.js | >=20 (CI tests 22 and 24) | | node-redis | 5, 6 | | ioredis | 5, 6 | | Redis server | 7 (tested) | | Module formats | CommonJS + ESM |

Usage

The primary example uses node-redis, passing the client directly through the client option (the client is duck-typed, so no adapter is required).

const { createClient } = require('redis');
const { RateLimiter, Unit } = require('redis-sliding-rate-limiter');

(async () => {
  const client = createClient({
    url: 'redis://localhost:6379',
  });

  await client.connect();

  // 10 requests every 3 seconds, allowing 10% of the limit (1 request) to exceed it.
  const limiter = new RateLimiter({
    client: client,
    window: {
      unit: Unit.SECOND,
      size: 3,
    },
    limit: 10,
    // Fraction of the limit allowed to exceed it, rounded down (10 * 0.1 = 1 extra request).
    limitOverhead: 0.1,
  });

  const key = 'OneRing';

  const results = await Promise.all([
    limiter.get(key), // 1
    limiter.get(key), // 2
    limiter.get(key), // 3
    limiter.get(key), // 4
    limiter.get(key), // 5
    limiter.get(key), // 6
    limiter.get(key), // 7
    limiter.get(key), // 8
    limiter.get(key), // 9
    limiter.get(key), // 10
    limiter.get(key), // 11 - allowed thanks to the overhead
    limiter.get(key), // 12 - denied
  ]);

  for (const res of results) {
    const { allowed, remaining, firstExpireAtMs, windowExpireAtMs } = res;
    console.log({ allowed, remaining, firstExpireAtMs, windowExpireAtMs });
  }

  // The first ten requests are allowed and `remaining` counts down to 0; the eleventh is still
  // allowed because of the overhead; the twelfth is denied. `firstExpireAtMs` and `windowExpireAtMs`
  // are epoch-millisecond timestamps derived from the Redis server clock.

  await client.quit();
})();

Client input: client vs sendCommand

A limiter talks to Redis through exactly one of two mutually exclusive options. The constructor throws if both are provided, and throws if neither is provided.

  • client — a Redis client instance. The limiter detects the command interface: node-redis exposes sendCommand(args), ioredis exposes call(command, ...args).
  • sendCommand — an explicit callback you wire yourself. This is useful for clients, pools or proxies that the duck-typing does not recognise:
type SendCommandFn = (...args: string[]) => Promise<unknown>;

Both forms below apply to majors 5 and 6 of each client.

node-redis

const { createClient } = require('redis');
const { RateLimiter, Unit } = require('redis-sliding-rate-limiter');

const client = createClient({ url: 'redis://localhost:6379' });
await client.connect();

// Passing the client directly.
const a = new RateLimiter({
  client: client,
  window: { unit: Unit.SECOND, size: 1 },
  limit: 5,
});

// The equivalent sendCommand callback.
const b = new RateLimiter({
  sendCommand: (...args) => client.sendCommand(args),
  window: { unit: Unit.SECOND, size: 1 },
  limit: 5,
});

ioredis

const Redis = require('ioredis');
const { RateLimiter, Unit } = require('redis-sliding-rate-limiter');

const client = new Redis({ host: 'localhost', port: 6379 });

// Passing the client directly.
const a = new RateLimiter({
  client: client,
  window: { unit: Unit.SECOND, size: 1 },
  limit: 5,
});

// The equivalent sendCommand callback.
const b = new RateLimiter({
  sendCommand: (...args) => client.call(args[0], ...args.slice(1)),
  window: { unit: Unit.SECOND, size: 1 },
  limit: 5,
});

How the sliding window works

Each key maps to a Redis sorted set. Every request is stored as a distinct member scored by the current microsecond timestamp read from the Redis server clock. On each call the limiter:

  1. Evicts members whose score falls outside the trailing window, so the window slides continuously rather than being bucketed into fixed intervals.
  2. Counts the members still inside the window; the count against limit (plus any overhead) decides allowed and remaining.
  3. Adds a member and refreshes the key TTL (PEXPIRE) only when the request is allowed. A denied request neither adds a member nor refreshes the TTL.

Response fields

limiter.get(key) resolves with a RateLimiterResponse:

| Field | Type | Meaning | | ------------------ | --------- | ------------------------------------------------------------------------------------------------------------ | | allowed | boolean | Whether this request is allowed. | | remaining | number | Requests still allowed after this one, clamped at 0. Computed against the base limit (overhead not counted).| | firstExpireAtMs | number | Epoch milliseconds at which the oldest member in the window expires (-1 when the window is empty). | | windowExpireAtMs | number | Epoch milliseconds at which the whole window empties (-1 when the window is empty). |

Changing the limit at runtime

Every setting used by get() is read at call time, so assigning limiter.limit, limiter.windowSize, limiter.windowUnit or limiter.limitOverheadFraction takes effect on the very next call, with no reload or reconnection. This makes it easy to drive the limit from a value stored in Redis and updated elsewhere: keep a dedicated subscriber connection (Redis does not allow a subscriber to run other commands) and reassign limiter.limit whenever a new value is published.

const Redis = require('ioredis');
const { RateLimiter, Unit } = require('redis-sliding-rate-limiter');

const client = new Redis({ host: 'localhost', port: 6379 });

const limiter = new RateLimiter({
  client: client,
  window: { unit: Unit.SECOND, size: 1 },
  limit: 5,
});

// A subscriber cannot issue other commands, so use a separate connection for pub/sub.
const subscriber = new Redis({ host: 'localhost', port: 6379 });

subscriber.psubscribe('ratelimit:limit:*');

subscriber.on('pmessage', (pattern, channel, message) => {
  const nextLimit = Number.parseInt(message, 10);
  // Ignore payloads that are not a positive integer.
  if (Number.isInteger(nextLimit) && nextLimit > 0) {
    limiter.limit = nextLimit;
  }
});

Unlike the constructor, the setter does not validate the assigned value, so guard it yourself as shown above. For per-request limits driven by the incoming request rather than a shared value, use the Express middleware's overrideLimit / overrideLimitFn instead.

Express middleware

The library exposes a middleware factory for Express. Each middleware evaluates one or more limiters per request.

const express = require('express');
const Redis = require('ioredis');
const { RateLimiter, Unit, createExpressMiddleware } = require('redis-sliding-rate-limiter');

(async () => {
  const app = express();

  const client = new Redis({
    host: 'localhost',
    port: 6379,
  });

  const middleware = createExpressMiddleware({
    // Limiters evaluated for each request, in order.
    limiters: [
      {
        limiter: new RateLimiter({
          client: client,
          window: {
            unit: Unit.SECOND,
            size: 1,
          },
          limit: 5,
        }),
        overrideKey: true,
        // Compute the Redis key from the request and limiter. Can also be defined at middleware level (below).
        overrideKeyFn: (req, limiter) => {
          return req.path + limiter.name;
        },
        key: 'This key will be overridden',
        errorMessage: '[Peak] Too many requests',
      },
      {
        limiter: new RateLimiter({
          client: client,
          window: {
            unit: Unit.HOUR,
            size: 1,
          },
          limit: 10000, // This will be overridden.
        }),
        overrideLimit: true,
        // Override the limiter limit. Can also be defined at middleware level (below).
        overrideLimitFn: (req, limiter) => {
          return parseInt(req.query.limit, 10); // Make sure this returns a positive integer.
        },
        // Optional per-limiter skip. Return true to skip evaluation of this limiter.
        skipFn: (req, limiter) => {
          return false;
        },
        errorMessage: '[Hourly] Too many requests',
      },
    ],

    // Middleware-level key override.
    // Fallback when a limiter has overrideKey enabled but provides no overrideKeyFn.
    overrideKeyFn: (req, limiter) => {
      return 'some key';
    },

    // Middleware-level limit override.
    // Fallback when a limiter has overrideLimit enabled but provides no overrideLimitFn.
    overrideLimitFn: (req, limiter) => {
      return 666;
    },

    // Status code returned when a request is throttled (default 429).
    errorStatusCode: 429,

    // Enable/disable setting rate-limit headers on the response (default true).
    setHeaders: true,

    // Custom function to set headers on the response. Called only when setHeaders is enabled;
    // when omitted, default X-Rate-Limit-* headers are set instead.
    setHeadersFn: (req, res, limiter, limiterResponse) => {
      const { remaining, firstExpireAtMs, windowExpireAtMs } = limiterResponse;
      res.set(`X-Rate-Limit-Remaining-${limiter.name}`, '' + remaining);
      res.set(`X-Rate-Limit-First-Expire-${limiter.name}`, '' + firstExpireAtMs);
      res.set(`X-Rate-Limit-Reset-${limiter.name}`, '' + windowExpireAtMs);
    },

    // Optional whitelist. Return true to skip rate limiting entirely for the request.
    skipFn: (req) => {
      return false;
    },

    // Called when a request is throttled (not allowed).
    onThrottleRequest: (req, res, key) => {
      return res.status(429).send(`Too many requests for key ${key}`);
    },
  });

  // Plug in the middleware.
  app.use(middleware);

  app.get('/', (req, res) => {
    return res.send('Yo!');
  });

  app.listen(8080, () => console.log('Server listening on port 8080...'));
})();

Running the tests

# start a local Redis (ephemeral)
docker compose up -d redis
# install deps, build, run the suite against Redis on localhost:6379
npm install
npm run build
npm test
# tear down
docker compose down

Tests default to REDIS_HOST=localhost / REDIS_PORT=6379 and exercise both node-redis and ioredis.

Migration

Upgrading from v6? See MIGRATION.md for the full list of breaking changes and what to do.

License

MIT License