Rate Limiting
Count requests per client, answer 429 with Retry-After, and put the tightest limits on your most expensive routes.
res.writeHead(429, { 'Retry-After': String(seconds) }).end();Rate limiting is refusing requests from one client once they arrive faster than you are willing to serve them. It is what keeps a login endpoint from being a password-guessing machine, and one noisy script from using the capacity your other users need.
A key, a count and a window
Pick a key that identifies the caller: a user id or API key if the request is authenticated, otherwise the client's IP address. Keep a count per key for a period of time, the window. While the count is under the limit, serve the request and increment. Once it is over, stop.
Answering a refusal
The status code is 429 Too Many Requests. Send a Retry-After header with it, as a whole number of seconds, so a well-behaved client knows when to come back instead of retrying immediately and making things worse. Make the refusal cheap: check the counter before you touch the database.
Limit the expensive routes first
A flat limit across every route is set either too low for a list endpoint or too high for a login attempt. Give each route the limit its cost deserves. Password login, password reset, signup, search, export and anything that sends email or calls a paid API need tight limits. A cached read can be generous.
Where the counter lives
A Map in the process is the simplest counter and it works until you run a second copy of your process. A replica is one running copy; with three replicas, three independent counters each allow the full limit. Shared state means Redis: INCR on a key with EXPIRE set the first time, which is the same algorithm with the count somewhere both copies can see it.
Example
When to use it
- Stop password guessing against a login endpoint without locking real users out of the service.
- Protect a report or export endpoint whose queries take seconds each.
- Cap a free API tier per API key, with 429 and Retry-After so clients can back off.
- Keep one buggy client script from consuming the capacity of a shared service.
More examples
The same window, counted in Redis
This is the in-memory limiter with the Map replaced by Redis, and nothing else changed. Move to it as soon as you run more than one replica, or as soon as a deploy restarting the process must not reset everyone's counter.
// INCR creates the key at 1 if it is missing and returns the new value,
// so the first caller in a window is the one that sets the expiry.
// Every replica increments the same key, so the limit is the real limit.
async function allow(key, limit, windowSeconds) {
const redisKey = `rl:${key}`;
const count = await redis.incr(redisKey);
if (count === 1) {
await redis.expire(redisKey, windowSeconds); // window starts now
}
if (count <= limit) {
return { allowed: true, remaining: limit - count };
}
const ttl = await redis.ttl(redisKey); // seconds left in the window
return { allowed: false, retryAfter: Math.max(1, ttl) };
}
// In the handler, refuse before doing any real work.
const verdict = await allow(clientKey(req), 60, 60);
if (!verdict.allowed) {
res.writeHead(429, {
'Retry-After': String(verdict.retryAfter),
'Content-Type': 'application/json'
});
res.end(JSON.stringify({ error: 'rate limited' }));
return;
}Choosing the key behind a proxy
Get this wrong in one direction and every request looks like it came from your load balancer, so the first user to hit the limit blocks everybody. Get it wrong in the other and any client sets its own key with a header. Trusting the forwarded header only from known proxy addresses is the line between the two.
const { createHash } = require('node:crypto');
const net = require('node:net');
// Only your own load balancer's addresses belong in here.
const trustedProxies = new net.BlockList();
trustedProxies.addSubnet('10.0.0.0', 8);
function clientKey(req) {
// Best key first: an identified caller cannot change identity by
// reconnecting from a different address.
if (req.user) return `user:${req.user.id}`;
const auth = req.headers.authorization;
if (auth) {
// Hash the credential so it never lands in a log line or a Redis key.
return 'key:' + createHash('sha256').update(auth).digest('hex').slice(0, 16);
}
const socketAddress = req.socket.remoteAddress;
// X-Forwarded-For is written by the client and appended to by each proxy.
// Read it only when the connection itself came from your proxy, and take
// the last entry - the one your proxy added. Everything before it is
// whatever the client chose to claim.
if (trustedProxies.check(socketAddress)) {
const chain = (req.headers['x-forwarded-for'] ?? '').split(',');
const nearest = chain[chain.length - 1]?.trim();
if (nearest) return `ip:${nearest}`;
}
return `ip:${socketAddress}`;
}A limit per route, sized by cost
Teams usually add rate limiting as one global number and discover it protects nothing expensive. Start from a table like this: list the routes that cost money, time or email, and give those real limits before you worry about the rest.
// The limit belongs with the route, not with the service. These numbers
// come from what the work costs, not from a round figure.
const LIMITS = {
'POST /login': { limit: 5, windowMs: 60_000 }, // guessing
'POST /password-reset': { limit: 3, windowMs: 3_600_000 }, // sends email
'POST /reports': { limit: 2, windowMs: 60_000 }, // slow query
'GET /search': { limit: 30, windowMs: 60_000 },
default: { limit: 300, windowMs: 60_000 } // cached reads
};
function limitFor(method, routePath) {
return LIMITS[`${method} ${routePath}`] ?? LIMITS.default;
}
// A failed login should also count more than a successful one, so hold a
// second counter keyed by the account being targeted, not just by the
// caller's address - otherwise one password is tried from many addresses.
const { limit, windowMs } = limitFor(req.method, route.path);
const verdict = await allow(`${route.path}:${clientKey(req)}`, limit, windowMs / 1000);
Discussion