The most surprising thing about rate limiting is that its primary goal isn’t just to stop bad actors; it’s to protect your own application from accidental overload caused by legitimate users.
Let’s see express-rate-limit in action. Imagine a simple Express app where we want to prevent any single IP address from making more than 100 requests to the /api route within a 15-minute window.
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
// Apply rate limiting to all requests
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again after 15 minutes'
});
app.use('/api', apiLimiter); // Apply the limiter to routes starting with /api
app.get('/api/data', (req, res) => {
res.json({ message: 'Here is your data!' });
});
app.get('/', (req, res) => {
res.send('Welcome!');
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
When a client makes a request to /api/data, express-rate-limit intercepts it. It checks a store (by default, an in-memory store) for the client’s IP address. If this IP has made fewer than 100 requests in the last 15 minutes, the request proceeds. If it has hit or exceeded the limit, the middleware sends back the configured message with a 429 Too Many Requests status code.
The core problem rate limiting solves is preventing Denial of Service (DoS) attacks, whether malicious or accidental. Without it, a single IP bombarding your server with requests can exhaust its resources (CPU, memory, network bandwidth), making it unavailable to everyone. express-rate-limit provides a straightforward way to implement this protection.
Internally, express-rate-limit uses a store to keep track of request counts per identifier (usually the IP address). The default MemoryStore is simple but has limitations in clustered environments or when the server restarts. For production, you’d typically switch to a more robust store like RedisStore or MongoStore. The windowMs defines the sliding window duration, and max sets the threshold within that window.
You have several levers to control the behavior:
windowMs: This is the duration of the time window in milliseconds.15 * 60 * 1000is 15 minutes. You can adjust this based on your application’s expected traffic patterns. Shorter windows catch bursts more aggressively, longer windows are more lenient.max: The maximum number of requests allowed from a single identifier within thewindowMs. Setting this too low can impact legitimate users; too high defeats the purpose.message: The response body sent when the limit is exceeded. A clear message helps users understand why they are being blocked.statusCode: The HTTP status code to return. Defaults to429.keyGenerator: A function to determine the unique identifier for rate limiting. By default, it usesreq.ip. This is crucial if you’re behind a proxy and need to get the actual client IP.store: As mentioned, the storage mechanism. Essential for persistence across server restarts or in multi-process/multi-server setups.skip: A function that can conditionally skip rate limiting for certain requests. Useful for allowing requests from trusted IPs or for specific endpoints.handler: A function to customize the response when a limit is exceeded, giving you more control than justmessage.
When you’re behind a proxy, req.ip might return the proxy’s IP address, not the actual client’s. To fix this, you need to configure Express to trust the proxy and ensure your keyGenerator uses the correct header.
// In your Express app setup, before routes:
app.set('trust proxy', 1); // Trust the first proxy
And then, if your proxy sets the X-Forwarded-For header, the default keyGenerator should work correctly. If you use a different header, you’d customize keyGenerator:
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req, res) => {
// Example: Get IP from custom header 'X-Real-IP'
return req.headers['x-real-ip'] || req.socket.remoteAddress;
},
message: 'Too many requests from this IP, please try again after 15 minutes'
});
The store option is where the real power and complexity lie for production deployments. The default MemoryStore loses all its state when the Node.js process restarts. For any application that needs to survive restarts or is scaled across multiple instances, you absolutely must use an external store like Redis. This ensures that the rate limit count is consistent across all instances of your application and persists through deployments. To use Redis, you’d install rate-limit-redis and configure it:
const redis = require('redis');
const { RedisStore } = require('rate-limit-redis');
const client = redis.createClient({
url: 'redis://localhost:6379' // Your Redis connection URL
});
client.on('error', (err) => console.log('Redis Client Error', err));
await client.connect();
const apiLimiter = rateLimit({
store: new RedisStore({
// @ts-ignore
sendCommand: (...args) => client.sendCommand(args),
}),
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many requests from this IP, please try again after 15 minutes'
});
This setup ensures that even if one server instance goes down or a new one spins up, the rate limiting counts are shared and accurate.
Once you have rate limiting set up, the next common challenge is handling distributed systems and ensuring your rate limiting strategy is applied consistently across multiple services or instances, especially when dealing with user accounts rather than just IP addresses.