Rate limiting caps how many requests a single client (usually identified by IP) can fire at our API in a given window. Without it, one bored attacker with a while true loop can melt our login endpoint or rack up our OpenAI bill.
In simple language: it’s a bouncer that counts how many times you’ve walked into the club tonight and stops letting you in after the limit.
Why we need it
- Stop brute-force attacks on
/login - Prevent scraping
- Protect expensive endpoints (LLM calls, image processing)
- Save our database from accidental client bugs (a buggy mobile app hammering our server)
The algorithms
Think of it like this: fixed window is a counter on the wall that resets every hour. Sliding window keeps a rolling list of timestamps. Token bucket is a leaky bucket of coins — you spend one per request, and coins drip back in over time.
Basic setup with express-rate-limit
npm install express-rate-limit
import rateLimit from "express-rate-limit";
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 100, // 100 requests per IP per window
standardHeaders: "draft-7",
legacyHeaders: false,
message: { error: "Too many requests, slow down." },
});
app.use("/api", apiLimiter);
Now any IP hitting /api/* more than 100 times in 15 minutes gets a 429 Too Many Requests.
Tighter limits for sensitive routes
The login endpoint is the juiciest target. Lock it down hard.
const loginLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 5,
skipSuccessfulRequests: true, // only count failed logins
keyGenerator: (req) => req.body.email || req.ip,
});
app.post("/login", loginLimiter, loginHandler);
skipSuccessfulRequests is gold for login — a legit user who typed their password right shouldn’t get punished. Only bad attempts count.
In-memory vs Redis store
By default, express-rate-limit keeps counters in memory. That works fine for a single-process server. The moment we scale to multiple Node instances behind a load balancer, the math breaks — each instance has its own counter, so an attacker effectively gets limit × instances.
Enter Redis.
npm install rate-limit-redis ioredis
import { RedisStore } from "rate-limit-redis";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const limiter = rateLimit({
windowMs: 60 * 1000,
limit: 100,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
});
Now all instances share the same counter. One source of truth.
Trade-offs
| Store | Pros | Cons |
|---|---|---|
| In-memory | Zero deps, fast | Breaks across instances, lost on restart |
| Redis | Shared state, survives restarts | Network hop, infra to manage |
Gotchas
- Behind a proxy? Set
app.set("trust proxy", 1)soreq.ipreads the real client IP fromX-Forwarded-For, not your load balancer’s IP. - Per-user vs per-IP: for authenticated routes, key on user ID instead of IP — otherwise a whole office NAT shares one bucket.
- Don’t rate-limit health checks: your monitoring will hate you.