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
- Library choice driven by need: middleware-and-headers (
express-rate-limit) vs penalty/primitive (rate-limiter-flexible - If using
rate-limiter-flexible, aninsuranceLimiter - If using
express-rate-limit, explicit fail-open vs fail-closed behavior set in a customhandler -
blockDurationchosen deliberately for abuse cases (e.g. 5 failed logins → -
RateLimit-*/Retry-After
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.
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.