Rate limiting is often treated as a simple guardrail, but its real power lies in its ability to shape traffic, not just block it.
Let’s see it in action. Imagine we have a simple Express.js app:
const express = require('express');
const app = express();
const port = 3000;
app.get('/data', (req, res) => {
res.send('Here is your data!');
});
app.listen(port, () => {
console.log(`App listening at http://localhost:${port}`);
});
Now, we want to protect the /data endpoint. We’ll use the express-rate-limit middleware.
First, install it:
npm install express-rate-limit
Then, integrate it:
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
const port = 3000;
// Apply to all requests
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: 'Too many requests from this IP, please try again after 15 minutes',
});
app.use(limiter);
app.get('/data', (req, res) => {
res.send('Here is your data!');
});
app.listen(port, () => {
console.log(`App listening at http://localhost:${port}`);
});
With this setup, any IP address making more than 100 requests to any endpoint on this server within a 15-minute window will receive a 429 Too Many Requests response. The windowMs defines the sliding window duration, and max sets the threshold within that window. The standardHeaders option is crucial for clients to understand their current rate limit status.
This simple middleware provides a foundational layer of protection. But rate limiting is far more nuanced. You can apply different limits to different routes. For instance, a high-traffic public endpoint might have a stricter limit than a sensitive administrative one.
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
const port = 3000;
// Global limiter for all requests
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000,
message: 'Too many requests from this IP, please try again after 15 minutes',
standardHeaders: true,
legacyHeaders: false,
});
// Stricter limiter for sensitive endpoints
const sensitiveLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 50, // 50 requests per hour
message: 'Too many requests to this sensitive endpoint, please try again after 1 hour',
standardHeaders: true,
legacyHeaders: false,
});
app.use(globalLimiter); // Apply global limiter to all routes
// Apply sensitive limiter specifically to /login and /register
app.use('/login', sensitiveLimiter);
app.use('/register', sensitiveLimiter);
app.get('/data', (req, res) => {
res.send('Here is your data!');
});
app.post('/login', (req, res) => {
res.send('Login attempt...');
});
app.post('/register', (req, res) => {
res.send('Registration attempt...');
});
app.listen(port, () => {
console.log(`App listening at http://localhost:${port}`);
});
Here, /login and /register are subject to a more aggressive limit (50 requests per hour) in addition to the global limit of 1000 requests per 15 minutes. This allows for more granular control.
The underlying mechanism of express-rate-limit typically involves storing request counts and timestamps. By default, it uses an in-memory store, which is fast but not shared across multiple Node.js processes or servers. For distributed systems, you’d integrate with a shared store like Redis.
const express = require('express');
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');
const app = express();
const port = 3000;
// Configure Redis client
const redisClient = redis.createClient({
url: 'redis://localhost:6379'
});
redisClient.connect().catch(console.error);
const limiter = rateLimit({
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per `window`
standardHeaders: true,
legacyHeaders: false,
message: 'Too many requests from this IP, please try again after 15 minutes',
});
app.use(limiter);
app.get('/data', (req, res) => {
res.send('Here is your data!');
});
app.listen(port, () => {
console.log(`App listening at http://localhost:${port}`);
});
This Redis-backed limiter ensures that rate limits are consistent across all instances of your application, a critical requirement in scalable deployments. The store.sendCommand is the key; it tells express-rate-limit how to interact with your chosen external store.
Beyond simple IP-based limiting, you can customize the keyGenerator function to use different identifiers. This could be a user ID after authentication, an API key, or even a combination of factors.
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
const port = 3000;
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: 'Too many requests from this IP, please try again after 15 minutes',
keyGenerator: (req, res) => {
// Example: Limit based on authenticated user ID if available, otherwise IP
if (req.user && req.user.id) {
return `user_${req.user.id}`;
}
return req.ip;
},
});
// Dummy authentication middleware for demonstration
app.use((req, res, next) => {
// In a real app, you'd authenticate and set req.user
if (req.query.apiKey === 'valid-key') {
req.user = { id: 'user123' };
}
next();
});
app.use(limiter);
app.get('/data', (req, res) => {
res.send('Here is your data!');
});
app.listen(port, () => {
console.log(`App listening at http://localhost:${port}`);
});
In this example, if a request includes ?apiKey=valid-key, it will be identified by user_user123 for rate limiting purposes. This allows for per-user throttling even if multiple users share the same IP address.
A subtle but powerful aspect of rate limiting is its impact on caching. If your API gateway or CDN respects the Retry-After header (which express-rate-limit can be configured to send), it can temporarily cache the 429 response, preventing further requests from even hitting your Node.js application for a specified period. This offloads significant pressure during bursts of traffic.
The next logical step is implementing more sophisticated strategies like token bucket or leaky bucket algorithms, which offer smoother rate limiting than simple fixed windows, preventing abrupt request drops.