express-rate-limit vs rate-limiter-flexible
Choosing between express-rate-limit and rate-limiter-flexible is the library decision every Node.js team faces once a single in-memory limiter stops being enough. This comparison sits under the Express.js Rate Limit Middleware guide, and it turns on what you actually need: a drop-in HTTP middleware with sane defaults, or a lower-level consume/block primitive that limits anything — logins, queue jobs, websocket messages — across many store backends. Pick express-rate-limit for the common “N requests per window on a route” case; reach for rate-limiter-flexible when you need block-duration penalties, true sliding behavior, multiple stores, or to rate-limit non-HTTP work.
Decision matrix
| Criterion | express-rate-limit |
rate-limiter-flexible |
|---|---|---|
| Primary shape | Express/Connect HTTP middleware | Library primitive (consume/block/penalty/reward) |
| Default algorithm | Fixed window (counter + TTL) | Fixed window, with sliding/token-style options and atomic Redis scripts |
| Store backends | Memory; Redis/Memcached/Mongo via adapter packages | Redis, Memcached, MongoDB, MySQL/Postgres, Cluster, plus in-memory |
| Block-duration penalty | No native long block beyond the window | Yes — blockDuration keeps a key blocked after exhaustion |
| Non-HTTP usage | No — coupled to req/res | Yes — limit logins, jobs, sockets, anything keyed |
| Insurance / fallback store | Manual (skipFailedRequests, custom handler) |
Built-in insuranceLimiter in-memory fallback on store outage |
Headers (RateLimit-*) |
Built-in (standardHeaders) |
Manual — you read consume() result and set headers |
| Atomicity | Store-dependent (Redis store uses scripts) | Atomic Redis Lua across all consume/penalty operations |
| Best fit | Standard “N per window per route” with minimal code | Penalty-based abuse control, multi-resource limits, multiple stores |
Selection rules:
- Use
express-rate-limitwhen you want the smallest amount of code to put a fixed window on a route, get429+RateLimit-*headers for free, and your store is memory or Redis. - Use
rate-limiter-flexiblewhen you need ablockDuration(e.g. lock an account for 15 min after 5 failed logins), a built-in in-memory fallback when the store is down, a non-Redis backend like Mongo or Postgres, or to rate-limit work that is not an HTTP request. - Use both:
express-rate-limitfor coarse per-route HTTP quotas andrate-limiter-flexiblefor targeted abuse penalties (brute-force login, scraping) where block-duration matters.
Step-by-step implementation
express-rate-limit — route middleware
// Minimal HTTP middleware: fixed window, 429 + RateLimit-* headers for free.
import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
export const apiLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
windowMs: 60_000, // 1-minute fixed window
max: 100, // 100 requests/key/window
standardHeaders: true, // emit IETF RateLimit-* headers
legacyHeaders: false,
keyGenerator: (req) => (req.headers["x-api-key"] as string) ?? req.ip,
});
// app.use("/api", apiLimiter);rate-limiter-flexible — consume/block primitive
// Lower-level: consume points, apply a block-duration penalty, set headers yourself.
import { RateLimiterRedis } from "rate-limiter-flexible";
import Redis from "ioredis";
import type { Request, Response, NextFunction } from "express";
const redis = new Redis(process.env.REDIS_URL!);
const limiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: "rl",
points: 100, // 100 requests
duration: 60, // per 60 seconds
blockDuration: 900, // once exhausted, block this key for 15 minutes
// falls back to an in-memory limiter if Redis is unreachable:
insuranceLimiter: undefined, // set to a RateLimiterMemory instance in prod
});
export async function flexibleLimit(req: Request, res: Response, next: NextFunction) {
const key = (req.headers["x-api-key"] as string) ?? req.ip!;
try {
const r = await limiter.consume(key, 1); // throws when no points remain
res.setHeader("RateLimit-Limit", "100");
res.setHeader("RateLimit-Remaining", String(r.remainingPoints));
res.setHeader("RateLimit-Reset", String(Math.ceil(r.msBeforeNext / 1000)));
next();
} catch (rejRes: any) {
// rejRes carries msBeforeNext even while the key is in its block window
res.setHeader("Retry-After", String(Math.ceil(rejRes.msBeforeNext / 1000)));
res.status(429).json({ error: "Too Many Requests" });
}
}Operator checklist
Gotchas & edge cases
express-rate-limitdefaults to a fixed window. It permits a 2× burst across the window boundary. If that matters, userate-limiter-flexible’s sliding options or a custom store.rate-limiter-flexible.consume()rejects by throwing. The rejection is aRateLimiterRes, not anError— destructuremsBeforeNext/remainingPoints, do not assume it has a.message.blockDurationoutlives the window. A blocked key stays blocked for the fullblockDurationeven after the points window would have refilled — that is the point, but it surprises people testing locally.- Headers are manual with
rate-limiter-flexible. You will not getRateLimit-*automatically; mapconsume()results to headers yourself or clients can’t back off intelligently. - Adapter version drift in
express-rate-limit.rate-limit-redisv4 uses a namedRedisStoreexport andsendCommand; older snippets using a default export will not compile.
Verification & testing
Confirm both libraries hold the aggregate limit across pods and that a block-duration actually blocks.
# 50 concurrent clients, same key, 10s — expect ~100 accepted/min total, the rest 429.
hey -z 10s -c 50 -H "X-API-Key: acct_42" https://api.example.com/api/resource \
| grep -E "Status code distribution" -A4
# For rate-limiter-flexible: after exhausting points, every call within blockDuration
# must keep returning 429 with a shrinking Retry-After, even if you pause.Frequently Asked Questions
Which library is faster?
Both are dominated by the Redis round-trip, not library overhead — expect the same order of magnitude. rate-limiter-flexible uses atomic Lua for all operations, and express-rate-limit with the Redis store does too, so per-request cost is comparable. Choose on features, not micro-benchmarks.
Can express-rate-limit block an account for 15 minutes after abuse?
Not natively — its counter resets at the end of windowMs. For a penalty that outlives the window you want rate-limiter-flexible's blockDuration, which keeps the key blocked after the points are exhausted regardless of refill.
Do I need Redis with rate-limiter-flexible?
No. It supports Redis, Memcached, MongoDB, MySQL/Postgres, Redis Cluster mode, and pure in-memory. The in-memory limiter is also what you wire as an insuranceLimiter so the app keeps limiting (approximately) when the shared store is unreachable.
Can I rate-limit non-HTTP work with these?
express-rate-limit is coupled to the Express request/response, so no. rate-limiter-flexible is a plain consume(key) primitive, so you can guard login attempts, queue jobs, websocket messages, or any keyed operation with the same limiter.
Whichever you adopt, wrap it. A thin internal module that exposes consume, headers, and a degradation policy keeps the choice reversible: swapping libraries later becomes one file rather than a change to every route in the application.
One practical tiebreaker: check which library your framework’s ecosystem already pulls in transitively. Running two limiter libraries in one process is not harmful, but it doubles the configuration surface and guarantees that a future maintainer tunes the wrong one.
Whichever you choose, verify the effective ceiling with a single-key load test against a deployment that has the production worker count. That measurement, not the library documentation, is what tells you whether the number you published is the number clients actually get.
Document the choice and its reason in the repository. Six months later the question “why do we use this one?” will be asked during an unrelated incident, and a one-paragraph answer in the code is worth more than an archaeology session through commit history.
Related
- Express.js Rate Limit Middleware — the parent guide on middleware placement and key resolvers.
- Configuring Express-Rate-Limit with Redis — wiring the Redis store for
express-rate-limit. - Redis Lua vs INCR Rate Limiting — the atomicity both libraries rely on under the hood.
- Fixed Window Counter Drift Explained — the boundary burst the default fixed window allows.