Rate limiting protects your API from abuse, prevents accidental overload, and ensures fair usage across clients. Without it, a single client (or attacker) can overwhelm your server. This guide implements robust rate limiting in Node.js from simple to production-grade.
📋 Table of Contents
Why Rate Limit?
- Prevent abuse: Stop brute-force attacks and scraping
- Ensure fairness: No single client monopolizes resources
- Control costs: Limit expensive operations (AI calls, DB queries)
- Protect stability: Prevent accidental or malicious overload
The Main Algorithms
| Algorithm | How It Works | Trade-off |
|---|---|---|
| Fixed Window | N requests per fixed time window | Simple, but allows bursts at window edges |
| Sliding Window | N requests in the last X seconds (rolling) | Smoother, slightly more complex |
| Token Bucket | Tokens refill over time; each request costs one | Allows controlled bursts, flexible |
Simple: express-rate-limit
npm install express-rate-limit
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true, // return RateLimit-* headers
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
});
// Apply globally
app.use(limiter);
// Or stricter limits on specific routes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // only 5 login attempts per 15 min
});
app.post('/login', authLimiter, loginHandler);
Production: Redis-Backed Rate Limiting
In-memory limits don’t work across multiple server instances. Redis shares limits across all instances:
npm install rate-limit-redis ioredis
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const limiter = rateLimit({
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
windowMs: 15 * 60 * 1000,
max: 100,
});
app.use(limiter);
// Now all server instances share the same rate-limit counters
Custom Token Bucket with Redis
async function tokenBucket(key, maxTokens, refillRate) {
const now = Date.now();
const bucket = await redis.hgetall(`bucket:${key}`);
let tokens = parseFloat(bucket.tokens ?? maxTokens);
let lastRefill = parseInt(bucket.lastRefill ?? now);
// Refill tokens based on elapsed time
const elapsed = (now - lastRefill) / 1000;
tokens = Math.min(maxTokens, tokens + elapsed * refillRate);
if (tokens < 1) {
return { allowed: false, retryAfter: (1 - tokens) / refillRate };
}
tokens -= 1; // consume one token
await redis.hset(`bucket:${key}`, { tokens, lastRefill: now });
await redis.expire(`bucket:${key}`, 3600);
return { allowed: true, remaining: Math.floor(tokens) };
}
// Middleware
async function rateLimitMiddleware(req, res, next) {
const result = await tokenBucket(req.ip, 10, 1); // 10 tokens, 1/sec refill
if (!result.allowed) {
res.setHeader('Retry-After', Math.ceil(result.retryAfter));
return res.status(429).json({ error: 'Rate limit exceeded' });
}
res.setHeader('X-RateLimit-Remaining', result.remaining);
next();
}
Per-User (Not Just Per-IP) Limiting
// Rate limit by authenticated user ID instead of IP
const userLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
keyGenerator: (req) => {
// Use user ID if authenticated, fall back to IP
return req.user?.id || req.ip;
},
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
});
// Tiered limits based on plan
function getLimitForUser(req) {
const plan = req.user?.plan || 'free';
return { free: 100, pro: 1000, enterprise: 10000 }[plan];
}
Setting Proper Response Headers
// Standard rate-limit headers help clients back off gracefully
res.setHeader('RateLimit-Limit', limit);
res.setHeader('RateLimit-Remaining', remaining);
res.setHeader('RateLimit-Reset', resetTimestamp);
// On limit exceeded, always include Retry-After
res.status(429)
.setHeader('Retry-After', secondsUntilReset)
.json({ error: 'Too many requests' });
Best Practices
- Use Redis for multi-instance apps — in-memory limits don't sync across servers
- Stricter limits on sensitive endpoints — login, password reset, and payment endpoints need tight limits
- Return clear headers — RateLimit-* and Retry-After help clients behave well
- Rate limit by user when authenticated — fairer than IP-only (shared IPs, proxies)
- Consider tiered limits — different limits for free vs paid users
- Combine with other defenses — rate limiting is one layer, not a complete security solution
Frequently Asked Questions
Q: Which algorithm should I use?
A: Token bucket for flexibility (allows controlled bursts), sliding window for smoothness, fixed window for simplicity. For most APIs, express-rate-limit's default (fixed/sliding window) is fine. Token bucket suits APIs where occasional bursts are acceptable.
Q: Should I rate limit by IP or user?
A: By user ID when authenticated (fairer, handles shared IPs), falling back to IP for anonymous requests. IP-only limiting can unfairly affect users behind shared NATs or corporate proxies.
Q: Why does my rate limit reset unexpectedly?
A: With in-memory limiting across multiple instances, each server has its own counter — a client hitting different instances sees inconsistent limits. Use Redis to share state across all instances.
Q: What status code should I return?
A: 429 Too Many Requests, with a Retry-After header telling the client how long to wait. This is the standard and well-behaved clients respect it.
Q: Can rate limiting stop DDoS attacks?
A: It helps against application-layer abuse but isn't a complete DDoS defense. For large-scale attacks, use a CDN/WAF (Cloudflare, AWS Shield) at the network edge in addition to application rate limiting.
Conclusion
Rate limiting is essential for protecting your Node.js API from abuse and overload. Start with express-rate-limit for simple cases, and move to Redis-backed limiting for multi-instance production apps so limits sync across servers. Apply stricter limits to sensitive endpoints (login, payments), rate limit by user when authenticated, and always return clear RateLimit-* and Retry-After headers so clients back off gracefully. Combine it with a CDN/WAF for defense in depth.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment