Rate limiting a Node.js API is one of those tasks that looks like a five-line job in a tutorial and turns into an incident at 3 a.m. in production. The middleware itself is easy. What breaks is everything around it: a load balancer that hides the real client IP, counters stored in memory while you run four containers, a 429 response with no Retry-After header, or a limit so aggressive that a legitimate mobile app gets locked out.
This guide is the production version of the walkthrough. We go from a working baseline to per-route policies, shared Redis counters, correct proxy configuration, standards-compliant headers, and a rollout plan that does not block real users.
Why rate limiting matters before you ship
Rate limiting bounds how many requests a given subject (an IP address, an API key, a user ID) can make inside a time window. Without it, a single misbehaving client can:
- Brute-force login, password reset or OTP endpoints
- Exhaust your database connection pool and take down healthy traffic
- Scrape your entire catalogue in minutes
- Run up your bill on paid downstream APIs (email, SMS, LLM providers)
- Trigger an accidental self-DDoS from a buggy client retry loop
Important framing: express-rate-limit is application-level protection. It is excellent against abuse, credential stuffing and runaway clients. It is not a replacement for a CDN or WAF against volumetric DDoS, because the request still reaches your Node.js process.

Step 1: The baseline setup
Install the middleware:
npm install express-rate-limit
Then wire a global limiter. This example uses ESM and the modern option names (limit instead of the deprecated max):
import express from 'express';
import { rateLimit } from 'express-rate-limit';
const app = express();
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 1000, // requests per window per key
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'too_many_requests' },
});
app.use(globalLimiter);
app.get('/api/health', (req, res) => res.json({ ok: true }));
app.listen(3000);
That is the whole “hello world”. Everything below is what separates it from a configuration you can actually deploy.
The options that actually matter
| Option | What it does | Production advice |
|---|---|---|
windowMs |
Length of the counting window in milliseconds | Short windows (1 min) react faster and punish less. Long windows (15 min) are better for auth endpoints. |
limit |
Max requests per window per key | Can be a function, so you can give paying tiers a higher ceiling. |
keyGenerator |
Decides what is being limited | Default is the client IP. Prefer API key or user ID when authenticated. |
store |
Where counters live | Memory by default. Must be Redis (or similar) as soon as you run more than one instance. |
standardHeaders |
Emits IETF RateLimit-* headers |
Use 'draft-8' on recent versions, or 'draft-7' if you are still on v7. |
legacyHeaders |
Old X-RateLimit-* headers |
Set to false unless existing clients depend on them. |
skip / skipFailedRequests / skipSuccessfulRequests |
Exclude requests from counting | Great for health checks, internal networks and login endpoints. |
handler |
Custom response when the limit is hit | Use it to return a consistent JSON error shape and to log the event. |
Step 2: Fix the proxy problem first
This is the single most common cause of broken IP-based rate limiting in Node.js. If your app sits behind Nginx, a cloud load balancer, Cloudflare, Heroku, Render or a Kubernetes ingress, then req.ip returns the proxy address unless you tell Express otherwise. The result: every user in the world shares one counter and everyone gets a 429 at the same time. This article covers the same ground in more depth.
// Number of proxies between the client and your app.
// 1 = a single load balancer. 2 = CDN + load balancer.
app.set('trust proxy', 1);
Never use app.set('trust proxy', true) on a public API. It tells Express to trust the leftmost value of X-Forwarded-For, which any client can forge. An attacker simply rotates a fake header value and bypasses your limiter entirely. express-rate-limit even ships validation checks that will warn you in the logs about this exact misconfiguration.
Safer patterns:
- Set the exact hop count:
app.set('trust proxy', 1) - Trust only known proxy ranges:
app.set('trust proxy', ['loopback', '10.0.0.0/8']) - Behind Cloudflare, read the vetted header instead: use
req.headers['cf-connecting-ip']in yourkeyGenerator
Verify it in 10 seconds
app.get('/api/debug/ip', (req, res) => {
res.json({ ip: req.ip, xff: req.headers['x-forwarded-for'] });
});
Call it from an external network. If ip is your real public address and not 127.0.0.1 or an internal 10.x address, your trust proxy setting is correct. Remove the route afterwards. Much the same conclusion turns up on akashrajpurohit.com.

Step 3: Per-route limits instead of one global number
A single global limiter is a blunt instrument. A product search endpoint and a password reset endpoint have nothing in common. Build a small factory and apply tailored policies.
// limiters.js
import { rateLimit } from 'express-rate-limit';
const base = {
standardHeaders: 'draft-8',
legacyHeaders: false,
};
export const readLimiter = rateLimit({
...base,
windowMs: 60 * 1000,
limit: 300,
});
export const writeLimiter = rateLimit({
...base,
windowMs: 60 * 1000,
limit: 60,
});
export const authLimiter = rateLimit({
...base,
windowMs: 15 * 60 * 1000,
limit: 8,
skipSuccessfulRequests: true, // only failed logins count
});
export const expensiveLimiter = rateLimit({
...base,
windowMs: 60 * 60 * 1000,
limit: 20, // PDF export, report generation, AI calls
});
Then mount them where they belong:
app.use('/api/', readLimiter);
app.post('/api/orders', writeLimiter, createOrder);
app.post('/api/auth/login', authLimiter, login);
app.post('/api/auth/forgot-password', authLimiter, forgotPassword);
app.post('/api/reports/export', expensiveLimiter, exportReport);
Order matters. Express runs middleware in registration order, so a route-specific limiter placed after a global one simply adds a second, stricter layer. That layering is intentional and useful: a wide safety net plus a tight guard on sensitive endpoints.
Suggested starting values
| Endpoint type | Window | Limit | Key |
|---|---|---|---|
| Public read / search | 1 min | 120 to 300 | IP |
| Authenticated read | 1 min | 600 to 1200 | API key / user ID |
| Write operations | 1 min | 30 to 60 | User ID |
| Login / password reset | 15 min | 5 to 10 failures | IP + account identifier |
| Email / SMS sending | 1 hour | 5 to 20 | User ID |
| Heavy export / AI calls | 1 hour | 10 to 50 | Account ID |
Treat these as starting points, then tune with real traffic data (see step 7).
Step 4: Choose the right key (and handle IPv6 correctly)
IP is the default, but it is the weakest signal. Mobile carriers and corporate offices put thousands of users behind one address, while a single attacker on IPv6 can rotate through a huge pool of addresses for free.
Rule of thumb: limit by identity when you have one, fall back to IP when you do not.
import { rateLimit, ipKeyGenerator } from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: (req) => (req.user?.plan === 'pro' ? 1200 : 120),
standardHeaders: 'draft-8',
legacyHeaders: false,
keyGenerator: (req, res) => {
if (req.user?.id) return `user:${req.user.id}`;
if (req.get('x-api-key')) return `key:${req.get('x-api-key')}`;
return `ip:${ipKeyGenerator(req.ip)}`;
},
});
Two things to note:
- Do not return a raw IPv6 address as a key. The built-in
ipKeyGeneratorhelper normalises IPv6 to a /64 subnet so a client cannot escape the limit by changing the last part of its address. Recent versions of the library will throw a validation error if you build an IP key without it. - Namespace your keys (
user:,key:,ip:) so an attacker cannot craft an API key value that collides with another subject’s counter.
Double keying for login endpoints
For authentication, limit by IP and by the account being targeted. That stops both a single host hammering many accounts and a distributed botnet hammering one account.
const loginIpLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
keyGenerator: (req) => `login-ip:${ipKeyGenerator(req.ip)}`,
skipSuccessfulRequests: true,
});
const loginAccountLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
keyGenerator: (req) => `login-acct:${String(req.body?.email || '').toLowerCase()}`,
skipSuccessfulRequests: true,
});
app.post('/api/auth/login', express.json(), loginIpLimiter, loginAccountLimiter, login);
Step 5: Return a proper 429 response
A good 429 is machine readable. Clients should be able to back off automatically instead of retrying in a tight loop and making things worse.
const limiter = rateLimit({
windowMs: 60 * 1000,
limit: 120,
standardHeaders: 'draft-8',
legacyHeaders: false,
handler: (req, res, next, options) => {
const retryAfter = Math.ceil(options.windowMs / 1000);
res.setHeader('Retry-After', retryAfter);
req.log?.warn({ key: req.ip, path: req.path }, 'rate_limit_exceeded');
res.status(options.statusCode).json({
error: 'too_many_requests',
message: 'Rate limit exceeded. Please slow down and retry.',
retry_after_seconds: retryAfter,
docs: 'https://geminiweb.net/docs/rate-limits',
});
},
});
Which headers clients should read
| Header | Meaning |
|---|---|
RateLimit (draft-8 combined field) |
Remaining quota and reset time for the active policy |
RateLimit-Policy |
Declares the quota and window so clients can self-throttle |
RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset (draft-6 and draft-7) |
Separate fields used by older drafts |
Retry-After |
Seconds to wait before retrying. Respected by many HTTP clients automatically |
If you already have public clients relying on X-RateLimit-Remaining, keep legacyHeaders: true during a deprecation period, then switch it off and document the change.

Step 6: Share counters across instances with Redis
The default memory store keeps counters inside one Node.js process. The moment you scale to 3 pods, a cluster of workers or a serverless platform, each instance keeps its own count. A limit of 100 silently becomes 300. Worse, counters reset on every deploy.
The fix is a shared store. For most teams that means Redis:
npm install rate-limit-redis redis
import { createClient } from 'redis';
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.on('error', (err) => console.error('redis_error', err));
await redisClient.connect();
export const apiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 300,
standardHeaders: 'draft-8',
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
prefix: 'rl:api:',
}),
});
Practical notes:
- Match major versions. The store interface changed between express-rate-limit v6, v7 and v8. Install the
rate-limit-redisrelease that matches your middleware version, or you will get cryptic runtime errors. - Use a prefix per limiter. Different policies must not share counters, and a prefix makes keys easy to inspect with
SCAN. - Keep Redis close. Every request adds a round trip. Same region, same VPC, ideally sub-millisecond latency.
- Decide your failure mode. If Redis is down, do you fail open (allow traffic) or fail closed (reject everything)? For public APIs, failing open with a memory fallback is usually the right call.
Fail-open fallback
let redisReady = false;
redisClient.on('ready', () => { redisReady = true; });
redisClient.on('end', () => { redisReady = false; });
const memoryBackup = rateLimit({ windowMs: 60 * 1000, limit: 300 });
app.use((req, res, next) =>
redisReady ? apiLimiter(req, res, next) : memoryBackup(req, res, next)
);
Other stores exist if Redis is not in your stack: Memcached, MongoDB, PostgreSQL and Cloudflare-backed options are all available in the ecosystem. The configuration pattern is identical: swap the store.
Step 7: Roll it out without blocking legitimate users
The riskiest part of rate limiting is the first deploy. Here is a safe sequence.
- Run in shadow mode. Keep counting but never reject, and log what would have been blocked.
- Collect data for one to two weeks. Look at the 99th percentile of requests per minute per key, not the average.
- Set the limit well above the p99 of real users, typically 3 to 5 times higher.
- Enable enforcement on low-risk routes first, then on writes, then globally.
- Alert on the 429 rate. A sudden spike usually means a bad limit or a broken client, not an attack.
Shadow mode in practice
const shadow = process.env.RATE_LIMIT_MODE === 'shadow';
const limiter = rateLimit({
windowMs: 60 * 1000,
limit: 120,
standardHeaders: 'draft-8',
legacyHeaders: false,
handler: (req, res, next, options) => {
if (shadow) {
console.warn('rate_limit_would_block', { ip: req.ip, path: req.path });
return next();
}
res.status(options.statusCode).json({ error: 'too_many_requests' });
},
});
Allowlists and exemptions
const INTERNAL = new Set((process.env.ALLOWLIST_IPS || '').split(',').filter(Boolean));
const limiter = rateLimit({
windowMs: 60 * 1000,
limit: 120,
skip: (req) =>
req.path === '/api/health' ||
req.path === '/metrics' ||
INTERNAL.has(req.ip) ||
req.user?.role === 'service-account',
});
Common exemptions worth configuring from day one:
- Health checks and readiness probes (otherwise your orchestrator can rate-limit itself)
- Monitoring and uptime agents
- Internal service-to-service calls from your own VPC
- Webhook receivers from trusted providers, which legitimately burst
OPTIONSpreflight requests, which browsers send automatically
Also consider pairing the limiter with express-slow-down. Instead of a hard wall, it adds progressive delay to each request past a threshold, which degrades aggressive clients gracefully while real users barely notice.
Step 8: Test it before your users do
A quick shell check:
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/api/items
done
echo
Inspect the headers on a single call:
curl -i http://localhost:3000/api/items | grep -i 'ratelimit\|retry-after'
For load behaviour, use autocannon:
npx autocannon -c 50 -d 10 http://localhost:3000/api/items
And an automated regression test so nobody weakens the policy by accident:
import request from 'supertest';
import app from '../src/app.js';
test('returns 429 after the limit is exceeded', async () => {
const limit = 5;
for (let i = 0; i < limit; i++) {
await request(app).get('/api/items').expect(200);
}
const res = await request(app).get('/api/items').expect(429);
expect(res.headers['retry-after']).toBeDefined();
});
Remember to reset the store between tests, or to inject a dedicated test limiter with tiny values.

express-rate-limit vs the alternatives
| Solution | Algorithm | Best for | Trade-off |
|---|---|---|---|
| express-rate-limit | Fixed window counter | Most Express APIs, fastest path to production | Burst possible at window boundaries |
| rate-limiter-flexible | Token bucket, block duration, insurance limiters | Complex multi-tier quotas, non-Express stacks, queueing | More configuration, steeper learning curve |
| express-slow-down | Progressive delay | Softening abuse without hard rejections | Holds connections open, use alongside a hard limit |
| CDN / WAF / API gateway | Edge rules | Volumetric attacks, blocking before your origin | No access to app-level identity such as user ID or plan |
The strongest setup is layered: edge rules for raw volume, express-rate-limit for per-identity business rules inside the app.
Production checklist
- ☐
trust proxyset to an exact hop count or known subnets, nevertrue - ☐
req.ipverified from an external network - ☐ Redis (or another shared store) configured for every non single-instance deployment
- ☐ Separate limiters for read, write, auth and expensive endpoints
- ☐ Keys namespaced and IPv6 normalised with
ipKeyGenerator - ☐
standardHeadersenabled,legacyHeadersdisabled,Retry-Afterpresent on 429 - ☐ Health checks, probes and internal services exempted via
skip - ☐ 429 events logged with key, route and user agent
- ☐ Alert configured on an abnormal 429 rate
- ☐ Limits documented publicly for API consumers
- ☐ Automated test covering the 429 path
Frequently asked questions
Does express-rate-limit work without Redis?
Yes. The default memory store is fine for a single process: a small VPS, a prototype, or a service that never scales horizontally. As soon as you run multiple instances, workers or serverless functions, switch to a shared store, otherwise your effective limit is multiplied by the number of instances.
What status code and headers should a rate-limited response use?
Use HTTP 429 Too Many Requests with a Retry-After header and the IETF RateLimit headers. Returning 403 or 503 instead confuses clients and breaks automatic retry logic in most HTTP libraries.
Why do all my users get rate limited at the same time?
Almost always a proxy configuration issue. Express is seeing the load balancer IP for every request, so all traffic shares a single counter. Set app.set('trust proxy', 1) (adjusting the number to your real hop count) and confirm that req.ip returns genuine client addresses.
Should I rate limit by IP or by API key?
By API key or user ID whenever the request is authenticated, because it is a far more accurate identity and it survives IP changes on mobile networks. Keep IP-based limiting for anonymous traffic and as a second layer on login endpoints. gitconnected.com goes into the numbers.
How do I pick the right limit value?
Measure before you decide. Run the limiter in shadow mode, record the 99th percentile of requests per minute per key for real users, then set your limit 3 to 5 times above it. Tighten later if abuse appears. Starting too strict generates support tickets and damaged trust.
Can rate limiting be bypassed?
IP-based limiting can be diluted by proxy pools and IPv6 rotation, and it can be bypassed completely if you blindly trust a forgeable X-Forwarded-For header. Mitigate with correct proxy trust settings, IPv6 subnet normalisation, identity-based keys, and an edge layer (CDN or WAF) in front of your origin.
Does rate limiting slow down my API?
With the memory store the overhead is negligible. With Redis you add one network round trip per request, typically well under a millisecond in the same region. That is an acceptable price compared to an outage caused by a single abusive client.
Final thoughts
Rate limiting a Node.js API properly is less about the middleware and more about the details around it: trusting the right proxy, choosing a meaningful key, sharing counters across instances, and shipping a 429 that clients can understand. Get those four right and express-rate-limit will quietly absorb the kind of traffic that would otherwise wake you up at night.
Need help hardening an existing Express or Node.js API? The team at geminiweb.net designs and audits production API infrastructure, from rate limiting and caching to observability and scaling.


