If you run a public Node.js API, sooner or later someone (or some bot) will hammer it with requests. Whether it’s a brute force attempt on your login endpoint, a scraper going wild, or simply a buggy client stuck in a retry loop, unprotected endpoints are a liability. Rate limiting in Node.js is the first line of defense, and in this guide we walk through how to implement it properly for production, not just in a toy example.
We will cover IP-based limits, user-based limits, distributed rate limiting with Redis, and the gotchas most tutorials skip (proxies, load balancers, and shared state across instances).
Why Rate Limiting Matters
Rate limiting controls how many requests a client can send to your server in a given time window. Without it, you expose your app to:
- Brute force attacks on login and password reset endpoints
- Denial of Service (DoS) from a single abusive IP
- Resource exhaustion (CPU, DB connections, third-party API quotas)
- Unexpected cloud bills when your infrastructure autoscales under attack

Choosing the Right Approach
Before writing code, pick the strategy that fits your architecture:
| Approach | Best For | Storage |
|---|---|---|
| express-rate-limit (in-memory) | Single instance apps, prototypes | Node process memory |
| express-rate-limit + Redis | Multi-instance, load-balanced APIs | Redis |
| rate-limiter-flexible | Advanced use (token bucket, block duration) | Redis, Memory, MongoDB |
| API Gateway (Nginx, Kong, AWS) | Infra-level protection | External |
For most Node.js teams, express-rate-limit combined with Redis hits the sweet spot. That’s what we build below.
Step 1: Basic IP-Based Rate Limiting
Start by installing the package:
npm install express express-rate-limit
Then wire it into your Express app:
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true, // send RateLimit-* headers
legacyHeaders: false, // disable X-RateLimit-* headers
message: {
error: 'Too many requests, please try again later.'
}
});
app.use('/api/', apiLimiter);
app.get('/api/hello', (req, res) => res.json({ ok: true }));
app.listen(3000);
That’s it for a single instance. But if you deploy behind a load balancer or run multiple Node processes, this will fail (each instance keeps its own counter). Time to add Redis.

Step 2: Distributed Rate Limiting with Redis
Install the Redis store for express-rate-limit:
npm install rate-limit-redis ioredis
Configure the limiter to share state across instances:
const express = require('express');
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis').default;
const Redis = require('ioredis');
const redisClient = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379
});
const app = express();
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args)
})
});
app.use('/api/', apiLimiter);
app.listen(3000);
Now every Node instance shares the same counters. Scaling horizontally no longer breaks your limits.
Fixing IP Detection Behind a Proxy
If you sit behind Nginx, Cloudflare, or an AWS load balancer, req.ip will be the proxy’s IP, not the client’s. All your users would share a single counter. Fix it with:
app.set('trust proxy', 1); // trust first hop
Set the number of proxies carefully. Trusting all proxies (true) opens you to IP spoofing via the X-Forwarded-For header.
Step 3: User-Based Rate Limiting
IP limits are blunt. Authenticated users deserve their own quota, tied to the user ID rather than the network address. Use the keyGenerator option:
const userLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args)
}),
keyGenerator: (req) => {
// Fall back to IP for unauthenticated requests
return req.user?.id ? `user:${req.user.id}` : `ip:${req.ip}`;
}
});
app.use('/api/', authenticate, userLimiter);
This gives you fine-grained control: authenticated users get their own counter, unauthenticated traffic still gets IP-limited.
Step 4: Stricter Limits on Sensitive Endpoints
Login and password reset endpoints need tighter limits. Chain a stricter limiter on top:
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 attempts per 15 min per IP
skipSuccessfulRequests: true, // only count failures
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args)
}),
message: { error: 'Too many failed attempts. Try again in 15 minutes.' }
});
app.post('/auth/login', loginLimiter, loginHandler);
app.post('/auth/reset-password', loginLimiter, resetHandler);
The skipSuccessfulRequests flag is key: it prevents locking out legitimate users while still blocking brute force attempts.

Step 5: Tiered Limits for Paid Users
If you sell API access, different plans should have different quotas. Build a dynamic limiter:
const tierLimits = {
free: { max: 60, windowMs: 60 * 1000 },
pro: { max: 600, windowMs: 60 * 1000 },
enterprise: { max: 6000, windowMs: 60 * 1000 }
};
const tieredLimiter = rateLimit({
windowMs: 60 * 1000,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args)
}),
keyGenerator: (req) => `user:${req.user.id}`,
max: (req) => tierLimits[req.user.plan || 'free'].max
});
app.use('/api/', authenticate, tieredLimiter);
Step 6: Return Useful Headers to Clients
Good API citizens tell clients where they stand. With standardHeaders: true, express-rate-limit sends:
- RateLimit-Limit: max requests allowed in the window
- RateLimit-Remaining: how many are left
- RateLimit-Reset: seconds until the counter resets
When the limit is hit, respond with HTTP 429 Too Many Requests (the library does this by default) and include a Retry-After header. Well-behaved clients will back off automatically.
Common Pitfalls to Avoid
- Not trusting the proxy correctly. Everyone gets the same limit or IP spoofing becomes trivial.
- In-memory store in a clustered app. Each Node worker keeps its own counter, so effective limits are multiplied by the number of workers.
- Rate limiting static assets. Apply limiters only to API routes, not to
/publicor/health. - Blocking health checks. Whitelist your monitoring IPs with the
skipoption. - Ignoring Redis failures. If Redis goes down, decide whether to fail open (allow all) or fail closed (block all). Wrap the store in a fallback.

When to Reach for rate-limiter-flexible
If you need advanced algorithms like token bucket, leaky bucket, block duration after abuse, or per-route dynamic points cost, look at rate-limiter-flexible. It’s more configurable but has a steeper learning curve. For 90% of REST APIs, express-rate-limit plus Redis is enough.
Testing Your Rate Limiter
A quick way to confirm everything works is with a shell loop:
for i in {1..110}; do
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/hello
done
You should see 200 for the first 100 requests and 429 after that. For load testing, tools like autocannon or k6 give you a more realistic picture.
FAQ
Does rate limiting replace a Web Application Firewall (WAF)?
No. Rate limiting is one layer. A WAF (Cloudflare, AWS WAF) blocks malicious patterns, bots, and known bad IPs before traffic even hits your Node.js server. Use both.
What’s the difference between express-rate-limit and rate-limiter-flexible?
express-rate-limit is simpler and Express-specific. rate-limiter-flexible is framework-agnostic, supports more algorithms (token bucket, leaky bucket), and offers block duration and consecutive failure tracking. Pick the first for simplicity, the second for complex scenarios.
Should I rate limit by IP or by user?
Both. IP limits protect unauthenticated endpoints (login, signup, public search). User limits enforce fair usage on authenticated endpoints. Combine them for full coverage.
What HTTP status code should I return?
Always use 429 Too Many Requests. Include a Retry-After header so clients know when to try again.
Will rate limiting slow down my API?
Minimally. In-memory stores add microseconds. Redis adds a network round trip (typically under 1ms if Redis is in the same region). The tradeoff is well worth it.
How do I handle Redis downtime?
Wrap the store with a try/catch fallback to in-memory limiting, or configure rate-limit-redis to fail open. Monitor Redis with alerts so you catch outages quickly.
Wrapping Up
Implementing rate limiting in Node.js is not just a checkbox for security audits. It protects your users, your servers, and your bill. Start with express-rate-limit for the basic case, move to Redis as soon as you scale beyond one instance, and layer stricter rules on sensitive endpoints. With the patterns above, your API is ready for the real world.
Need help architecting a production-grade Node.js backend? Box Software builds and hardens APIs for teams that can’t afford downtime. Get in touch and we’ll audit your setup.
