import type { RequestHandler } from 'express';
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
export function rateLimit(opts: { windowMs: number; max: number; key: (req: any) => string }): RequestHandler {
return (req, res, next) => {
const now = Date.now();
const key = opts.key(req);
const b = buckets.get(key);
if (!b || b.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
res.setHeader('x-ratelimit-remaining', String(opts.max - 1));
return next();
}
b.count += 1;
const remaining = Math.max(0, opts.max - b.count);
res.setHeader('x-ratelimit-remaining', String(remaining));
res.setHeader('x-ratelimit-reset', String(Math.ceil((b.resetAt - now) / 1000)));
if (b.count > opts.max) {
return res.status(429).json({ error: 'RATE_LIMITED' });
}
next();
};
}
A single abusive client can ruin your latency budget for everyone else, so I rate limit early rather than trying to ‘detect abuse’ after the outage starts. I combine an IP bucket with a user bucket: IP protects unauthenticated endpoints, user protects authenticated endpoints behind NATs. For small deployments I’ll start in-memory, but once you run multiple instances I back it with Redis so limits are consistent. I’m also explicit about what I’m protecting (login, password reset, search) and I return 429 with x-ratelimit-remaining / x-ratelimit-reset so well-behaved clients can back off. This makes your service more predictable under load and reduces noisy incidents.