Rate Limiting & Throttling
A public Express endpoint with no usage limits is an open invitation: a single client can hammer your login route to guess passwords, scrape your API thousands of times a second, or simply exhaust your database connections. Rate limiting caps how many requests a given client may make in a time window, returning 429 Too Many Requests once the budget is spent. Combined with throttling — deliberately slowing responses rather than rejecting them — it turns brute-force and denial-of-service attacks from cheap into impractical. This page covers the de facto standard middleware, express-rate-limit, from a basic global limiter through per-route rules, a shared Redis store for multiple instances, and slowing down repeated login attempts.
Installing and configuring express-rate-limit
The express-rate-limit package is a small, dependency-light middleware. Install it and mount it before your routes so every request is counted.
npm install express-rate-limit
const express = require("express");
const rateLimit = require("express-rate-limit");
const app = express();
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: "draft-7", // RateLimit-* response headers (RFC draft)
legacyHeaders: false, // disable old X-RateLimit-* headers
message: { error: "Too many requests, please try again later." },
});
app.use(limiter); // applies to every route below
app.get("/api/products", (req, res) => {
res.json({ products: [] });
});
app.listen(3000);
By default the limiter keys each client by IP address using an in-memory store. When a client exceeds max, the middleware short-circuits the request and sends the configured message with a 429 status. The standardHeaders option advertises the remaining budget so well-behaved clients can self-throttle.
Output:
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 412
Retry-After: 412
Content-Type: application/json
{"error":"Too many requests, please try again later."}
Common options
| Option | Type | Purpose |
|---|---|---|
windowMs | number | Length of the rolling window in milliseconds. |
max | number / function | Max requests per window per key (or a function returning it). |
standardHeaders | string / boolean | Emit RFC RateLimit-* headers ("draft-7" recommended). |
legacyHeaders | boolean | Emit legacy X-RateLimit-* headers. |
keyGenerator | function | Derive the client key (e.g. API key instead of IP). |
skip | function | Return true to bypass limiting for a request. |
handler | function | Custom responder when the limit is hit. |
store | object | Backing store (memory by default; Redis, etc.). |
Warning: If your app runs behind a proxy or load balancer, the default IP key will be the proxy’s address, lumping all users into one bucket. Call
app.set("trust proxy", 1)so Express reads the real client IP fromX-Forwarded-For. Set it to the number of hops you trust, not blindly totrue.
Per-route limits
A single global limit is rarely ideal: a read-heavy listing endpoint can tolerate far more traffic than a login or password-reset route. Create separate limiters and attach them only where needed, either inline on a route or mounted on a Router.
const express = require("express");
const rateLimit = require("express-rate-limit");
const router = express.Router();
// Strict limit on authentication
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // only 5 attempts per 15 min per IP
message: { error: "Too many login attempts. Try again in 15 minutes." },
});
// Generous limit on public reads
const readLimiter = rateLimit({ windowMs: 60 * 1000, max: 300 });
router.post("/login", authLimiter, 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) });
});
router.get("/feed", readLimiter, async (req, res) => {
res.json(await loadFeed());
});
module.exports = router;
Because middleware is just a function in the chain, you can stack a global limiter for baseline protection and add a stricter one to sensitive routes — both will run.
Distributed limiting with a Redis store
The default in-memory store counts requests inside a single Node process. Run two instances behind a load balancer and each keeps its own counter, so the effective limit doubles. For any horizontally scaled deployment you need a shared store. Redis is the standard choice via rate-limit-redis.
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 wires the limiter to your Redis client
sendCommand: (...args) => redisClient.sendCommand(args),
}),
});
app.use(limiter);
Now every instance increments the same atomic counter in Redis, so the limit holds across the whole fleet. Redis also survives restarts, meaning a deploy won’t reset every client’s budget.
Slowing brute-force login attempts
Hard rejection isn’t always the best tool. With brute-force password guessing you can frustrate the attacker without ever returning an error by progressively delaying each response. The companion package express-slow-down adds latency once a threshold is crossed.
npm install express-slow-down
const slowDown = require("express-slow-down");
const loginSpeedLimiter = slowDown({
windowMs: 15 * 60 * 1000,
delayAfter: 3, // first 3 requests are full speed
delayMs: (hits) => hits * 500, // then +500ms per extra request
maxDelayMs: 10_000, // cap the penalty at 10 seconds
});
// Combine throttling (slow) with a hard ceiling (reject)
router.post("/login", loginSpeedLimiter, authLimiter, async (req, res) => {
// ...authenticate as above
});
A legitimate user typing one wrong password notices nothing, while a script firing hundreds of guesses grinds to a crawl long before authLimiter cuts it off entirely. Pairing the two gives a smooth, attacker-hostile curve.
Tip: Key brute-force protection by username plus IP, not IP alone. A shared NAT (office, campus, mobile carrier) puts many users behind one address, and IP-only limits would lock them out together. Use a custom
keyGeneratorto combine the target account with the client IP.
Best Practices
- Mount a permissive global limiter for baseline DoS protection, then layer stricter per-route limits on login, signup, and password-reset endpoints.
- Always configure
trust proxycorrectly behind a proxy or CDN, or every client collapses into one rate-limit bucket. - Use a shared store (Redis) the moment you run more than one instance — in-memory counters silently multiply your limits.
- Return RFC
RateLimit-*headers (standardHeaders: "draft-7") so honest clients can back off before being blocked. - Prefer throttling with
express-slow-downover hard blocks for login flows, and key by account + IP to avoid punishing shared networks. - Never rate-limit by a header the client controls (like a raw API key) without verifying it first — an attacker can rotate it to dodge the limit.
- Monitor
429rates: a sudden spike is an early signal of an attack or a misbehaving client.