Skip to content

iboruch/portable-rate-limit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

portable-rate-limit

CI npm version License: MIT

A lightweight Node.js token-bucket rate limiter with a portable core API, Express middleware, TypeScript definitions, an in-memory store, and an atomic Redis store for multi-instance deployments.

Features

  • Portable check() API for any Node.js application
  • Express-style middleware
  • In-memory token bucket store for single-process apps
  • Atomic Redis-backed store for distributed deployments
  • TypeScript definitions through index.d.ts
  • Standard rate limit headers
  • Custom store interface for other persistence backends
  • Support for weighted requests through cost
  • Test suite running on Node.js 18, 20 and 22

Install

npm install portable-rate-limit

For Redis-backed rate limiting, install a Redis client in your application:

npm install redis

TypeScript support

portable-rate-limit is implemented in dependency-free JavaScript and ships with TypeScript definitions for the public API.

This keeps the runtime lightweight while still providing editor support and safer integration in TypeScript projects.

Typed API coverage includes:

  • createRateLimiter()
  • expressRateLimit()
  • rate limit result types
  • limiter options
  • custom store interface
  • RedisStore
import {
  createRateLimiter,
  RedisStore,
  type RateLimitResult
} from 'portable-rate-limit';

Quick Start

const { createRateLimiter } = require('portable-rate-limit');

const limiter = createRateLimiter({
  limit: 10,
  windowMs: 60_000
});

async function handleLogin(userId) {
  const result = await limiter.check({ key: `login:${userId}` });

  if (!result.allowed) {
    throw new Error(`Try again in ${Math.ceil(result.retryAfterMs / 1000)} seconds`);
  }

  return doLogin(userId);
}

Express Middleware

const express = require('express');
const { expressRateLimit } = require('portable-rate-limit');

const app = express();

app.use(expressRateLimit({
  limit: 100,
  windowMs: 15 * 60 * 1000,
  keyGenerator: ({ req }) => req.ip
}));

app.get('/', (req, res) => {
  res.json({ ok: true });
});

app.listen(3000);

When a request is blocked, the middleware returns HTTP 429 and sets the following headers:

  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset
  • Retry-After

Redis Store

Use RedisStore when your application runs across multiple Node.js processes, servers, containers, pods, or serverless instances that need to share the same rate limit state.

const express = require('express');
const { createClient } = require('redis');
const { RedisStore, expressRateLimit } = require('portable-rate-limit');

async function main() {
  const redis = createClient({
    url: process.env.REDIS_URL || 'redis://localhost:6379'
  });

  await redis.connect();

  const app = express();

  app.use(expressRateLimit({
    limit: 100,
    windowMs: 15 * 60 * 1000,
    store: new RedisStore({ client: redis }),
    keyGenerator: ({ req }) => req.ip
  }));

  app.listen(3000);
}

main().catch(console.error);

RedisStore uses a Lua EVAL script for token consumption. This keeps the read, refill, decrement, write and TTL update atomic, so concurrent requests from different application instances cannot overwrite each other.

The store supports the common command shapes used by redis and ioredis:

  • client.eval(script, { keys, arguments })
  • client.eval(script, numKeys, ...keys, ...args)

Architecture

The public API is split into three layers:

  • createRateLimiter() is the portable core. It accepts plain inputs such as { key } or framework-oriented inputs such as { req, res }.
  • expressRateLimit() adapts the core to Express-style middleware and standard rate limit response headers.
  • Stores isolate persistence. MemoryStore is local to one process. RedisStore is shared and atomic. Custom stores can implement get / set, or consume() for atomic backends.

The algorithm is based on a token bucket:

  • limit is the bucket size.
  • windowMs is the time needed to fully refill the bucket.
  • each request consumes cost, defaulting to 1.
  • blocked requests return retryAfterMs and resetAt.

Options

createRateLimiter({
  limit: 60,
  windowMs: 60_000,
  keyGenerator: ({ req, key }) => key || req.ip,
  skip: ({ req }) => false,
  onLimit: (input, result) => {},
  store: customStore,
  now: Date.now
});

limit

Maximum number of tokens available in the bucket.

windowMs

Time in milliseconds required to fully refill the bucket.

keyGenerator

Function used to generate a rate limit key. Useful for IP-based, user-based, account-based or API-key-based limits.

skip

Optional function for bypassing rate limiting for selected requests.

onLimit

Optional callback called when a request is blocked.

store

Persistence layer. Defaults to an in-memory store.

now

Optional time provider, useful for tests.

Custom Store Interface

Simple stores can implement get, set and optionally delete:

const store = {
  async get(key) {
    return bucket;
  },

  async set(key, bucket) {
    // bucket = { tokens, updatedAt, expiresAt }
  },

  async delete(key) {}
};

Distributed stores should implement consume() so the update is atomic:

const store = {
  async consume(key, { cost, limit, windowMs, now }) {
    return {
      allowed: true,
      tokens: 59,
      remaining: 59,
      resetAt: new Date(now + 1000),
      retryAfterMs: 0
    };
  }
};

Production Notes

The default in-memory store is intended for single-process Node.js applications, local tools and simple services.

For horizontally scaled APIs, serverless deployments or multiple Node.js instances, use a shared store such as Redis, Valkey or a database-backed implementation. This keeps rate limit state consistent across all running instances.

Recommendations:

  • Use MemoryStore only for one process. It does not share state across workers, pods, containers or serverless instances.
  • Use RedisStore for distributed rate limiting.
  • Keep Redis close to the application region to avoid adding latency to every request.
  • Choose stable keys. For authenticated endpoints, prefer user ID, account ID, API key or a normalized IP fallback.
  • If your app is behind a proxy, configure Express trust proxy before using req.ip.
  • Use route-specific limiters for sensitive flows such as login, password reset, checkout, webhooks or expensive API endpoints.
  • Use cost for endpoints with different weights, such as bulk operations or AI/API calls.
  • Decide fail-open vs fail-closed behavior at the application boundary if Redis is unavailable. This package surfaces store errors to the Express middleware through next(error).

Examples

Development

npm install
npm test
npm pack --dry-run

CI runs the test suite on Node.js 18, 20 and 22.

License

MIT

About

Easy rate limiter for your apps

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors