TL;DR
The #1 rate limiting best practice is applying different limits for different endpoints. Auth and expensive work get strict caps. Public reads can be generous. After that: sliding windows, key the limit on the authenticated user rather than the IP alone, a real 429 with a Retry-After header, and Redis once you run more than one server.
"Rate limiting is your API's immune system. Without it, a single bad actor can bring down your entire service and bankrupt your cloud budget."
Why Rate Limiting Matters
Rate limiting protects against:
- Brute force attacks: Login attempts, password resets
- DDoS: Overwhelming your servers
- Scraping: Automated data extraction
- API abuse: Excessive usage beyond plan limits
- Cost attacks: Running up your cloud bills
Best Practice 1: Different Limits for Different Endpoints 5 min
A login form and a public product listing are not the same risk, so they shouldn't share a budget. The login route is the one people forget, because it doesn't feel like an API.
import rateLimit from 'express-rate-limit';
// General API: 100 requests per 15 minutes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
// Login: 5 attempts per 15 minutes
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'Too many login attempts' },
skipSuccessfulRequests: true,
});
// Password reset: 3 per hour
const resetLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
});
// Expensive operations: 10 per hour
const expensiveLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 10,
});
// Apply limits
app.use('/api/', apiLimiter);
app.post('/api/auth/login', loginLimiter);
app.post('/api/auth/reset-password', resetLimiter);
app.post('/api/generate', expensiveLimiter);
Best Practice 2: Identify Users Correctly 3 min
IP alone stops working the moment people log in. One user on a train changes IP every few minutes. One attacker can sit behind a thousand.
const userLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req) => {
// Use user ID for authenticated requests
if (req.user?.id) {
return `user:${req.user.id}`;
}
// Fall back to IP for unauthenticated
return `ip:${req.ip}`;
},
skip: (req) => {
// Skip rate limiting for admins
return req.user?.role === 'admin';
},
});
Best Practice 3: Use Redis for Distributed Systems 5 min
In-memory counters break as soon as you run a second instance. Each server counts only its own traffic, so three servers quietly means triple the limit you set.
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';
const redisClient = createClient({
url: process.env.REDIS_URL,
});
await redisClient.connect();
const limiter = rateLimit({
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
windowMs: 15 * 60 * 1000,
max: 100,
});
Best Practice 4: Return Proper Headers 2 min
Tell the caller what happened. A well-behaved integration backs off on its own if you hand it the numbers.
// Response headers to include:
RateLimit-Limit: 100 // Max requests allowed
RateLimit-Remaining: 42 // Requests remaining
RateLimit-Reset: 1640000000 // When limit resets (Unix timestamp)
Retry-After: 120 // Seconds until they can retry (on 429)
// Example 429 response
{
"error": "Too many requests",
"retryAfter": 120
}
Best Practice 5: Sliding Window Algorithm 3 min
A fixed window has an edge you can drive through. Send your full quota in the last second of one window and again in the first second of the next. You've used double the limit without breaking it.
| Algorithm | Pros | Cons |
|---|---|---|
| Fixed Window | Simple, low memory | Burst at window boundary |
| Sliding Window | Smooth, no burst | More complex |
| Token Bucket | Allows controlled bursts | More complex |
Recommended Limits
| Endpoint Type | Recommended Limit |
|---|---|
| General API | 100-1000/hour |
| Login | 5-10/15 minutes |
| Password reset | 3-5/hour |
| Email sending | 10/hour |
| AI/expensive | 10-50/hour |
| Public read | 1000+/hour |
Official Resources: For comprehensive rate limiting guidance, see OWASP Denial of Service Cheat Sheet, Google Cloud Rate Limiting Strategies, and express-rate-limit documentation.
Should I rate limit by IP or user ID?
Both. User ID catches one account hammering you. IP catches brute force against accounts nobody has logged into yet. You need both, because plenty of attacks come from one IP driving many accounts.
How do I handle rate limiting behind a proxy?
Configure your app to trust the proxy and read the real IP from X-Forwarded-For header. In Express: app.set('trust proxy', 1). Don't let it trust arbitrary headers from anyone.
Should I tell users when they are rate limited?
Yes. Return a 429 with a Retry-After header and a message that says what happened. Real users and automated clients both back off correctly when you tell them.
Further Reading
Put these practices into action with our step-by-step guides.
Check Your Rate Limiting
Scan your API for missing rate limits.