Skip to content
Express.js ex libraries 6 min read

express-rate-limit

Any public endpoint can be hammered — by a script scraping your API, a bot spraying passwords at your login route, or a buggy client retrying in a tight loop. express-rate-limit caps how many requests a given client can make in a time window and rejects the excess with 429 Too Many Requests, turning abuse into a manageable trickle. It plugs in as ordinary middleware, counts requests per client key (the IP by default), and works either with its built-in in-memory store or a shared store like Redis when you run more than one instance. This page covers configuring the window and limit, scoping limiters to specific routes, customizing the client key, and wiring up a distributed Redis store.

Installing and configuring a basic limiter

The package exports a factory that returns a middleware. Install it, create a limiter with a window and a maximum, and register it with app.use() so it guards everything that follows.

npm install express-rate-limit
const express = require("express");
const rateLimit = require("express-rate-limit");

const app = express();

// Allow each IP 100 requests per 15-minute window.
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: "draft-7", // RateLimit-* response headers
  legacyHeaders: false, // disable old X-RateLimit-* headers
  message: { error: "Too many requests, please try again later." },
});

app.use(limiter);

app.get("/", (req, res) => res.send("Hello"));

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

The limiter tracks each client and decrements a counter per request. Once the count exceeds max inside the window, further requests are short-circuited with a 429 and your message body, and the RateLimit-* headers tell well-behaved clients how much budget remains.

Output:

HTTP/1.1 429 Too Many Requests
RateLimit-Policy: 100;w=900
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 412
Retry-After: 412
Content-Type: application/json

{"error":"Too many requests, please try again later."}

Behind a reverse proxy or load balancer, req.ip reports the proxy’s address, so every client looks identical and shares one bucket. Call app.set("trust proxy", 1) (or the correct hop count) so Express reads the real client IP from X-Forwarded-For. Setting trust proxy to true blindly is unsafe — it lets clients spoof their IP.

Key options

The factory accepts a handful of options that cover most needs. The most important ones are listed below.

OptionTypePurpose
windowMsnumberLength of the rolling window in milliseconds (default 60000).
max / limitnumber or functionMax requests per window per key. A function can return a per-client limit.
standardHeadersstring or booleanEmit IETF RateLimit-* headers; use "draft-7".
keyGeneratorfunctionReturns the identifier a client is counted against.
skipfunctionReturn true to bypass the limiter for a request.
handlerfunctionCustom responder invoked when the limit is hit.
storeobjectBacking store; defaults to in-memory, swap for Redis.

Per-route limiters

A single global limit is rarely the right policy. Authentication and other expensive endpoints deserve a much tighter cap than read-only routes. Because each rateLimit() call returns an independent middleware with its own counters, you can apply different limiters to different routes or routers.

const express = require("express");
const rateLimit = require("express-rate-limit");

const router = express.Router();

// Strict limiter: 5 login attempts per 15 minutes per IP.
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  standardHeaders: "draft-7",
  legacyHeaders: false,
  // Don't count successful logins against the limit.
  skipSuccessfulRequests: true,
  message: { error: "Too many login attempts. Try again in 15 minutes." },
});

router.post("/login", loginLimiter, async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: "Invalid credentials" });
  res.json({ token: issueToken(user) });
});

module.exports = router;

With skipSuccessfulRequests, only failed attempts erode the budget, so legitimate users who log in cleanly never trip the limit while brute-force guessing is throttled hard.

Custom key generators

By default a client is identified by IP, but you often want to rate-limit per authenticated user or per API key so that several users behind one NAT do not starve each other. Provide a keyGenerator that returns whatever string should bucket the request.

const apiLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 60,
  standardHeaders: "draft-7",
  legacyHeaders: false,
  // Bucket by API key when present, otherwise fall back to IP.
  keyGenerator: (req) => req.get("x-api-key") || req.ip,
  // Premium keys get a higher ceiling.
  max: (req) => (req.get("x-api-key")?.startsWith("pk_pro_") ? 600 : 60),
});

app.use("/api", apiLimiter);

When you write a custom keyGenerator, never return req.ip without trust proxy configured behind a proxy, and avoid keying on headers a client can forge unless they are verified (for example a validated API key).

Distributed limiting with a Redis store

The default in-memory store keeps counters in the process heap. That is fine for a single instance, but the moment you run multiple Node processes or containers behind a load balancer, each has its own counts and a client effectively gets max requests per instance. A shared store fixes this by holding counters in one place. rate-limit-redis backs the limiter with Redis so every instance sees the same totals.

npm install rate-limit-redis redis
const rateLimit = require("express-rate-limit");
const { RedisStore } = require("rate-limit-redis");
const { createClient } = require("redis");

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: "draft-7",
  legacyHeaders: false,
  store: new RedisStore({
    // sendCommand lets the store talk to node-redis directly.
    sendCommand: (...args) => redisClient.sendCommand(args),
    prefix: "rl:",
  }),
});

app.use(limiter);

Now all instances increment the same Redis keys, so a max of 100 means 100 across the whole fleet, and counters survive process restarts. The store uses Redis’s atomic increments, so concurrent requests across instances are counted correctly without race conditions.

The middleware contract is unchanged between Express 4.x and 5.x — the same app.use(limiter) and per-route usage work on both. On Express 5, async route handlers that reject are forwarded to your error handler automatically, but the limiter itself behaves identically.

Best Practices

  • Set trust proxy to the correct hop count before relying on req.ip behind a proxy, and never set it to a blanket true in production.
  • Apply a tight, dedicated limiter to authentication and password-reset routes, and use skipSuccessfulRequests so only failed attempts count.
  • Use a shared store such as Redis whenever you run more than one instance — in-memory counters silently multiply your effective limit.
  • Emit IETF headers with standardHeaders: "draft-7" and disable legacyHeaders so clients can back off gracefully via Retry-After.
  • Key by a verified identifier (authenticated user or validated API key) rather than a spoofable header when you need per-user limits.
  • Tune windowMs and max to real traffic — start generous, watch your 429 rate, and tighten only the endpoints that actually get abused.
  • Layer rate limiting with helmet and input validation rather than treating it as your only line of defense.
Last updated June 14, 2026
Was this helpful?