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.
- 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
npm install portable-rate-limitFor Redis-backed rate limiting, install a Redis client in your application:
npm install redisportable-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';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);
}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-LimitX-RateLimit-RemainingX-RateLimit-ResetRetry-After
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)
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.
MemoryStoreis local to one process.RedisStoreis shared and atomic. Custom stores can implementget/set, orconsume()for atomic backends.
The algorithm is based on a token bucket:
limitis the bucket size.windowMsis the time needed to fully refill the bucket.- each request consumes
cost, defaulting to1. - blocked requests return
retryAfterMsandresetAt.
createRateLimiter({
limit: 60,
windowMs: 60_000,
keyGenerator: ({ req, key }) => key || req.ip,
skip: ({ req }) => false,
onLimit: (input, result) => {},
store: customStore,
now: Date.now
});Maximum number of tokens available in the bucket.
Time in milliseconds required to fully refill the bucket.
Function used to generate a rate limit key. Useful for IP-based, user-based, account-based or API-key-based limits.
Optional function for bypassing rate limiting for selected requests.
Optional callback called when a request is blocked.
Persistence layer. Defaults to an in-memory store.
Optional time provider, useful for tests.
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
};
}
};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
MemoryStoreonly for one process. It does not share state across workers, pods, containers or serverless instances. - Use
RedisStorefor 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 proxybefore usingreq.ip. - Use route-specific limiters for sensitive flows such as login, password reset, checkout, webhooks or expensive API endpoints.
- Use
costfor 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).
npm install
npm test
npm pack --dry-runCI runs the test suite on Node.js 18, 20 and 22.
MIT