express-rate-limit
Middleware that counts requests per client over a rolling window and answers 429 once the count passes your limit. You mount rateLimit({ windowMs, limit }) globally or on specific routes, and it tracks hits by IP address by default, writes RateLimit headers, and exposes the current state on req.rateLimit. The counter lives in an in-process memory store unless you plug in a shared one such as Redis. Version 8 is ESM-first with a CommonJS build, needs Node 16 or newer, and declares express >= 4.11 as a peer dependency, so it works on both Express 4 and 5.
The default choice for putting a floor under abuse on an Express app, and the v8 line is genuinely well maintained. It only does what you configure, though: without a shared store and a correct trust proxy setting, the numbers it enforces are not the numbers you think you set.
Use it if
- You have public Express endpoints (login, password reset, signup, search) that need a cheap ceiling on request volume per client
- You want standards-compliant RateLimit headers so clients can back off, including the newer draft-8 combined header format
- You need per-route rules with different windows, for example five password resets an hour but three hundred API reads a minute, composed as ordinary middleware
- You already run Redis or Memcached and can share counters across every process and container with a drop-in store
- You run more than one process or container and do not add a shared store: the default MemoryStore counts per process, so four workers means four times your intended limit, and every deploy resets the counters
- You expect this to stop a determined attacker. Keys default to IP address, and IPs are rotated by botnets and shared by whole offices and mobile carriers, so you are choosing between blocking real users and letting abuse through
- You are behind a proxy or CDN and are unwilling to configure app.set('trust proxy', n) precisely: too low and every client shares the proxy's IP, too high and a caller can spoof X-Forwarded-For to reset their own counter. The library's built-in validation warns about this, and people disable the warning instead of fixing it
- Your edge already does rate limiting (Cloudflare, nginx limit_req, an API gateway): counting again in Node only happens after the connection, TLS handshake, and body parse have already cost you
- You are on Fastify, Koa, or Hono. This is Express middleware; those frameworks have their own plugins
- You need quota accounting rather than request counting, such as per-token cost or leaky-bucket refill: rate-limiter-flexible models those directly
Setup reality
npm install express-rate-limit and mount it, then spend an afternoon on the parts that actually matter. The package is ESM-first with a CJS build, so require() works but the docs and examples are all import syntax. Defaults are deliberately tiny (windowMs 60000, limit 5), so shipping without overriding them will lock out real users. Behind any proxy you must set app.set('trust proxy', <number of hops>) before mounting, and the built-in validation checks will throw loud startup errors if the X-Forwarded-For chain and trust setting disagree. A shared store is a second install (rate-limit-redis pins a peer range against the core version, so upgrading one usually means upgrading both) plus a Redis client you have to connect yourself. If you write a custom keyGenerator, wrap the IP in the exported ipKeyGenerator helper or the validator will reject it, because raw IPv6 addresses would give each client an effectively unlimited pool of keys.
Patterns
Apply a global limitbasic-limiter
import express from 'express';
import { rateLimit } from 'express-rate-limit';
const app = express();
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 100,
standardHeaders: 'draft-8',
legacyHeaders: false,
});
app.use(limiter);Defaults are windowMs 60000 and limit 5, which is far stricter than almost any real app wants. Always set both explicitly.
Tighter limits on sensitive routesper-route-limiter
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
skipSuccessfulRequests: true,
message: { error: 'Too many attempts, try again in an hour.' },
});
app.post('/login', authLimiter, loginHandler);
app.post('/password-reset', authLimiter, resetHandler);Sharing one limiter instance across routes means they share the counter. Call rateLimit() twice if you want /login and /password-reset budgeted separately.
Share counters across processes with Redisredis-store
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import { createClient } from 'redis';
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
store: new RedisStore({
sendCommand: (...args) => client.sendCommand(args),
prefix: 'rl:api:',
}),
});Without this, each Node process keeps its own counts and a rolling deploy hands everyone a fresh quota. Set a distinct prefix per limiter or two limiters will silently share keys.
Get the real client IP behind a proxytrust-proxy
// one hop: your own nginx / load balancer
app.set('trust proxy', 1);
// Cloudflare in front of your own proxy: two hops
// app.set('trust proxy', 2);
app.use(rateLimit({ windowMs: 60_000, limit: 60 }));Never use app.set('trust proxy', true) with a public limiter: it trusts the whole X-Forwarded-For chain, so a client can prepend a fake IP per request and never hit the limit. The startup validator flags this as ERR_ERL_PERMISSIVE_TRUST_PROXY.
Limit by API key or user id instead of IPcustom-key-generator
import { rateLimit, ipKeyGenerator } from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 60_000,
limit: 120,
keyGenerator: (req) =>
req.user?.id ?? req.get('x-api-key') ?? ipKeyGenerator(req.ip),
});Returning req.ip directly is rejected by the validator: raw IPv6 addresses give one client a near-infinite key space, so fall back through ipKeyGenerator, which masks to a /56 subnet by default.
Different limits per plandynamic-limit
const limiter = rateLimit({
windowMs: 60_000,
limit: (req) => (req.user?.plan === 'pro' ? 1000 : 60),
identifier: (req) => (req.user?.plan === 'pro' ? 'pro' : 'free'),
});limit, message, skip, and identifier all accept functions and may be async. identifier only shows up in the draft-8 RateLimit header as the policy name.
Send your own 429 responsecustom-handler
const limiter = rateLimit({
windowMs: 60_000,
limit: 60,
handler: (req, res, _next, options) => {
const retryAfter = Math.ceil(options.windowMs / 1000);
res.status(options.statusCode).json({
error: 'rate_limited',
retryAfter,
limit: req.rateLimit.limit,
remaining: req.rateLimit.remaining,
});
},
});handler overrides both message and statusCode. req.rateLimit carries limit, used, remaining, resetTime, and key, and is available in later middleware as well.
Exempt health checks and internal trafficskip-requests
const limiter = rateLimit({
windowMs: 60_000,
limit: 60,
skip: (req) =>
req.path === '/healthz' || req.ip === '127.0.0.1',
});skip runs before the counter increments, so exempted requests cost nothing. Do not skip on a header the client controls unless it is verified first.
Decide what happens when Redis is downstore-failure-behavior
const limiter = rateLimit({
windowMs: 60_000,
limit: 60,
store: redisStore,
passOnStoreError: false, // default: block traffic if the store errors
logger: { error: (err, msg) => log.error({ err }, msg) },
});The default fails closed, so a Redis outage returns 429 to everyone. Setting passOnStoreError to true fails open and removes your limit entirely during the outage; pick deliberately rather than by accident.
Read the current quota in a later handlerread-limit-state
app.use(limiter);
app.get('/quota', (req, res) => {
const { limit, used, remaining, resetTime } = req.rateLimit;
res.json({ limit, used, remaining, resetTime });
});Rename the property with requestPropertyName if you mount several limiters on one route, otherwise the last one to run overwrites the first one's info.
Tune IPv6 groupingipv6-subnet
const limiter = rateLimit({
windowMs: 60_000,
limit: 60,
ipv6Subnet: 64, // more lenient; 48 or 52 is more aggressive
});A single home connection is routinely handed a whole /64 or larger, so counting per IPv6 address lets one client cycle addresses freely. The default masks to /56.
Silence a specific validation warningvalidation-checks
const limiter = rateLimit({
windowMs: 60_000,
limit: 60,
keyGenerator: (req) => req.get('cf-connecting-ip') ?? 'unknown',
validate: { ip: false }, // we deliberately do not use req.ip
});Disable individual checks by name, never the whole set with validate: false. Those checks exist because misconfigured proxies are the most common way this middleware ends up enforcing nothing.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| rate-limiter-flexible | npm | You need token buckets, per-cost consumption, block durations, insurance limiters, or a backend other than Redis, on any framework |
| express-slow-down | npm | You would rather add delay as traffic climbs than hard-reject at a cliff; it is by the same maintainers and composes with this one |
| @fastify/rate-limit | npm | Your server is Fastify and you want the framework's own plugin instead of Express middleware |