🥊 Node.js Rate Limiting: Stop Letting Everyone Punch Your API Unlimited Times
Imagine you opened a hot dog stand. Business is booming. Then one guy walks up and orders 5,000 hot dogs — right now, all at once. Your grill melts. Your suppliers weep. The line of normal customers stretches around the block. Nobody gets a hot dog today.
That's your API without rate limiting.
Rate limiting is the bouncer at your API's door. It says: "You get 100 requests per minute, pal. After that, go sit in the corner." It's not mean — it's necessary. Without it, your server is just a fancy stress test waiting to happen.
Why Rate Limiting Isn't Optional
Here's the thing about the internet: it's full of bots, scrapers, and the occasional over-caffeinated developer who forgot to add a delay to their polling loop. Without rate limiting:
- DDoS attacks take your API offline — even unsophisticated ones
- Credential stuffing bots hammer your
/loginendpoint until something gives - Scraper abuse maxes out your database connections pulling every product at warp speed
- Runaway clients (including your own) accidentally DDOS you when something goes wrong
Rate limiting is your first line of defense. It's cheap, easy to implement, and will save you a 3 AM pager alert someday.
The Quick Win: express-rate-limit
The fastest way to add rate limiting to an Express app is the aptly named express-rate-limit package. Install it once, regret nothing:
npm install express-rate-limit
Then drop it in:
import express from 'express';
import rateLimit from 'express-rate-limit';
const app = express();
// Global limiter — applies to all routes
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true, // Send RateLimit-* headers
legacyHeaders: false,
message: {
error: 'Too many requests. Slow down, champ.',
retryAfter: '15 minutes',
},
});
app.use(globalLimiter);
app.get('/api/products', (req, res) => {
res.json({ products: [] });
});
app.listen(3000);
That's it. Every IP address now gets 100 requests per 15-minute window. Hit the limit? They get a 429 Too Many Requests response and a polite (if sarcastic) message.
The standardHeaders: true option is worth highlighting — it sends RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers, which well-behaved clients can use to back off gracefully instead of hammering you until they're blocked.
Targeted Limiting: Different Rules for Different Routes
A blanket rate limit is fine for casual protection, but smart rate limiting is surgical. Your /login endpoint needs stricter limits than /api/products. A user browsing your catalog should get more breathing room than someone trying passwords.
import rateLimit from 'express-rate-limit';
// Strict limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // only 10 attempts per minute
message: { error: 'Too many login attempts. Take a breath.' },
skipSuccessfulRequests: true, // don't count successful logins against the limit
});
// Relaxed limit for read-heavy public API
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 300,
message: { error: 'Slow down — this is an API, not a race.' },
});
// Apply targeted limits
app.post('/auth/login', authLimiter, loginController);
app.post('/auth/register', authLimiter, registerController);
app.use('/api', apiLimiter);
skipSuccessfulRequests: true on auth routes is a nice touch — a human logging in successfully shouldn't eat into their allowance. Only the failed attempts count. This keeps the UX smooth for real users while still throttling brute-force attempts.
Going Further: Redis-Backed Rate Limiting
The default express-rate-limit stores request counts in memory. That works perfectly for a single server. But the moment you scale to two instances, each server has its own counter — and a determined bot can now send 200 requests per window just by load-balancing across your servers.
The fix is a shared store. Redis is the go-to:
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({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
});
app.use('/api', limiter);
Now all your server instances share the same counter. The bot hits instance A twice and instance B once — three strikes tracked in one place. No loopholes.
Redis also gives you atomic increment operations, so you don't have to worry about race conditions inflating (or deflating) your counts under heavy load.
Practical Tips That Actually Matter
1. Rate limit by user ID, not just IP — IPs are shared. A corporate office might have 500 employees behind a single NAT. If your limit triggers on IP alone, you'll block the whole company when one developer writes a chatty script. For authenticated routes, use req.user.id as the key:
const userLimiter = rateLimit({
keyGenerator: (req) => req.user?.id ?? req.ip,
max: 500,
windowMs: 60 * 1000,
});
2. Set meaningful Retry-After headers — When you reject a request, tell the client when to try again. The standardHeaders option handles this automatically. Clients that respect headers won't hammer you; they'll wait.
3. Don't rate limit health checks — Your load balancer pings /health every few seconds. If that endpoint is behind a strict limiter, your own infrastructure might trigger alerts. Whitelist it explicitly:
const limiter = rateLimit({
skip: (req) => req.path === '/health',
max: 100,
windowMs: 60 * 1000,
});
4. Log limit violations — A spike in 429 responses is a signal. Maybe you're being probed. Maybe a client deployed a bug. Either way, you want to know. Log the IP, the endpoint, and the timestamp.
The Takeaway
Rate limiting is one of those features that feels optional until the moment it isn't. Adding it takes 20 minutes. Not having it can cost you hours of downtime and a very uncomfortable conversation with your team.
Start with a global limiter. Add stricter rules to your auth endpoints. If you're running multiple servers, back it with Redis. Then go home and sleep soundly knowing your API has a bouncer at the door.
Your hot dog stand is safe. ğŸŒ
What's your rate limiting setup look like? Are you using express-rate-limit, rolling your own with Redis, or living on the edge with no limits at all? Drop a comment or find me on GitHub — I'm always curious how teams handle this in production.