rate-limiter-flexible
rate-limiter-flexible is a counter and rate-limiting toolkit for Node.js applications. A limiter gives each key, usually an IP address, account ID, token, or route-qualified identifier, a number of points for a time window and rejects consumption after those points run out. The same API works with process memory, Node clusters, Redis or Valkey, MongoDB, DynamoDB, SQL databases, Prisma, Drizzle, and Memcached. Beyond basic request throttling it supports weighted actions, temporary blocks, penalties and rewards, combined limits, FIFO queues, in-process shielding of a remote store, and an emergency insurance limiter.
A capable choice when limits are part of application policy and must share state across processes, but it makes you own the key design, proxy trust, datastore, and outage semantics. For ordinary Express or Fastify request throttling, the framework-specific middleware is easier to configure correctly.
Use it if
- You need the same consume, block, reward, and inspect API across in-memory and distributed backends
- You need atomic counters in Redis, Valkey, MongoDB, DynamoDB, or SQL rather than a best-effort per-process middleware counter
- You are protecting login, password-reset, API, WebSocket, job, or third-party quota operations that need different point costs and keys
- You need to combine per-IP and per-account rules, queue callers, or keep a local emergency limiter for store failures
- You want a drop-in Express middleware with proxy handling and standard headers already decided; the project wiki shows middleware you write yourself, while express-rate-limit is purpose-built for that narrower job
- Your application has several workers or servers but you plan to use RateLimiterMemory: its state belongs to one process, so each process grants a separate allowance and restarts erase the counters
- You cannot define what happens when the backing store fails; without an insurance limiter, store errors reject with Error, while an in-memory insurance limiter can allow extra actions because its counts are never copied back to the main store
- You need a sliding-window or token-bucket algorithm with precisely documented edge behavior: the default is a flexible fixed window that starts on the first event, and execEvenly delays accepted work rather than changing that counter model
- You assume IP limiting alone stops distributed attacks: the Express guide warns that a broadly trusted X-Forwarded-For header can be spoofed, and a botnet naturally spreads requests across many IP keys
Setup reality
Install rate-limiter-flexible and use RateLimiterMemory for a single-process prototype. Current documentation marks Node 20 or newer as supported. The package has no production dependencies, so Redis, Valkey, MongoDB, PostgreSQL, Prisma, or another backing client must be installed, connected, monitored, and closed by your application. Redis needs read and write command categories plus EVAL and EVALSHA for the atomic script. With ioredis, the wiki recommends disabling the offline queue so requests do not pile up during an outage and replay later; with node-redis 4+, set useRedisPackage: true, and the wiki notes a cluster-mode problem in node-redis 4.7.0 that was fixed in v5. Give every limiter purpose its own keyPrefix or counters with different policies can collide. Decide whether a store failure should fail closed, fail open, or fall back to an insurance limiter, then test that behavior. Insurance memory is per process and its counts are not reconciled to the main store after recovery. SQL and DynamoDB limiters may create tables by default, which can require DDL permissions; set tableCreated when provisioning schema separately. Serverless SQL deployments also need explicit expired-row cleanup because the background timeout may not live long enough. Finally, normalize IPv6 and proxy-derived addresses deliberately, qualify user keys by route or action, and set Retry-After from msBeforeNext rather than hard-coding the window.
Patterns
Limit actions in one processlimit-in-memory
import { RateLimiterMemory } from 'rate-limiter-flexible';
const limiter = new RateLimiterMemory({
points: 10,
duration: 60,
keyPrefix: 'api-minute',
});
await limiter.consume(userId);Memory state is not shared across workers or servers and disappears on restart, so this is only a global limit in a single process.
Return 429 and rate-limit headers from Expressexpress-middleware
app.use(async (req, res, next) => {
try {
const result = await limiter.consume(req.ip);
res.set('X-RateLimit-Remaining', String(result.remainingPoints));
next();
} catch (rejection) {
if (rejection instanceof Error) return next(rejection);
const seconds = Math.max(1, Math.ceil(rejection.msBeforeNext / 1000));
res.set('Retry-After', String(seconds));
res.status(429).send('Too Many Requests');
}
});Configure Express trust proxy to specific proxies or hop counts; trusting arbitrary X-Forwarded-For input lets callers choose the key.
Share atomic limits through ioredisshare-with-redis
import Redis from 'ioredis';
import { RateLimiterRedis } from 'rate-limiter-flexible';
const redis = new Redis(process.env.REDIS_URL, { enableOfflineQueue: false });
redis.on('error', (error) => console.error('Redis', error));
const limiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'login-ip',
points: 20,
duration: 60,
blockDuration: 300,
});Redis must permit read and write commands plus EVAL and EVALSHA. Disabling the offline queue avoids replaying a backlog after recovery.
Use the node-redis clientuse-node-redis
import { createClient } from 'redis';
import { RateLimiterRedis } from 'rate-limiter-flexible';
const redis = createClient({ url: process.env.REDIS_URL });
redis.on('error', console.error);
await redis.connect();
const limiter = new RateLimiterRedis({
storeClient: redis,
useRedisPackage: true,
points: 100,
duration: 60,
keyPrefix: 'public-api',
});The useRedisPackage flag is for node-redis 4+. The project wiki says node-redis 4.7.0 cluster mode is incompatible and recommends v5 where that client issue is fixed.
Charge different point costsweight-expensive-actions
const cost = operation === 'export-report' ? 10 : 1;
try {
await limiter.consume(`${userId}:${operation}`, cost);
await runOperation();
} catch (rejection) {
if (rejection instanceof Error) throw rejection;
throw new TooManyRequestsError(rejection.msBeforeNext);
}The second consume argument is points, not seconds. A route-qualified key isolates budgets; remove the operation suffix if actions should share one pool.
Block and later clear a keytemporary-block
await limiter.block(`account:${accountId}`, 15 * 60);
// Administrative unblock or successful recovery flow
await limiter.delete(`account:${accountId}`);A block duration of 0 means the key never expires. delete removes all counter data for the key, not only a separate block flag.
Penalize failures and reward successpenalize-and-reward
const key = `login:${accountId}`;
if (passwordIsWrong) {
await limiter.penalty(key, 2);
} else {
await limiter.reward(key, 1);
}Penalty and reward adjust the current counter and can spill across durations depending on timing; delete is clearer when success should reset the whole key.
Use a local insurance limiter during Redis errorsfallback-on-store-error
import { RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible';
const insuranceLimiter = new RateLimiterMemory({ points: 20, duration: 60 });
const limiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'api',
points: 20,
duration: 60,
rejectIfRedisNotReady: true,
insuranceLimiter,
});Insurance counts are per process and are never copied back to Redis, so an outage can grant more actions than the nominal distributed limit.
Stop repeatedly hitting Redis for an exhausted keyshield-remote-store
const limiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'login',
points: 5,
duration: 60,
blockDuration: 300,
inMemoryBlockOnConsumed: 5,
inMemoryBlockDuration: 300,
});The local block applies only to consume calls in the current process. Matching the distributed block duration keeps worker behavior less surprising.
Require both IP and account budgetscombine-ip-and-account
import { RateLimiterMemory, RateLimiterUnion } from 'rate-limiter-flexible';
const perIp = new RateLimiterMemory({ points: 100, duration: 60, keyPrefix: 'ip' });
const perAccount = new RateLimiterMemory({ points: 20, duration: 60, keyPrefix: 'account' });
const union = new RateLimiterUnion(perIp, perAccount);
await union.consume(`${req.ip}:${accountId}`);RateLimiterUnion passes the same key to every limiter. Use separate consume calls if the IP and account components must be counted independently across different pairings.
Queue instead of rejecting outbound callsqueue-outbound-work
import { RateLimiterMemory, RateLimiterQueue } from 'rate-limiter-flexible';
const rate = new RateLimiterMemory({ points: 5, duration: 1 });
const queue = new RateLimiterQueue(rate, { maxQueueSize: 100 });
await queue.removeTokens(1);
await callThirdPartyApi();Queueing holds callers and can exhaust memory under overload; set maxQueueSize and handle rejection rather than allowing an unbounded backlog.
Inspect and reset the current counterinspect-limit-state
const state = await limiter.get(userId);
if (state) {
console.log({
consumed: state.consumedPoints,
remaining: state.remainingPoints,
resetsInMs: state.msBeforeNext,
});
}
await limiter.delete(userId);get returns null for a missing or expired key. Exposing this state in an admin endpoint can reveal user identifiers, so protect and audit that endpoint.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| express-rate-limit | npm | Choose it for conventional Express request throttling with a ready-made middleware interface and standard headers |
| @fastify/rate-limit | npm | Choose it when Fastify is the application framework and rate limiting should follow its plugin and hook model |
| bottleneck | npm | Choose it for scheduling outbound work, concurrency caps, reservoirs, and queued jobs rather than rejecting inbound abuse |
| limiter | npm | Choose it for a smaller token-bucket or interval limiter when distributed database adapters are unnecessary |