mrkeyoor.com_
Thu 06 Aug 01:03 UTC
npmWeb Backendupdated 05 Aug 2026

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.

Verdict

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.

API stability4/5Options have stayed recognizable across majors and renames keep working (max still functions after being renamed to limit in v7). But v7 and v8 each moved defaults and key generation, notably IPv6 subnet grouping, so upgrades change who gets blocked even when your config file is untouched.
Docs5/5Every option is documented with defaults, types, and caveats on a hosted site, plus dedicated pages for stores, proxies, and the validation error codes. The README table links straight to each option's reference entry.
Maintenance5/58.6.2 shipped one day before this review, commits are steady, backport tags exist for the 2.x, 3.x, and 8.0 through 8.2 lines, and only four open issues sit on the tracker.
Ecosystem4/5Around 61M weekly downloads with official stores for Redis, Memcached, MongoDB, and Postgres, plus sibling packages express-slow-down and ratelimit-header-parser. Express-only by design, so none of it transfers if you change framework.

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
Skip it if

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

PackageRegistryPick it when
rate-limiter-flexiblenpmYou need token buckets, per-cost consumption, block durations, insurance limiters, or a backend other than Redis, on any framework
express-slow-downnpmYou 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-limitnpmYour server is Fastify and you want the framework's own plugin instead of Express middleware