Add Rate Limiting Middleware
Add a fixed-window rate limiter to an Express service, return 429 with Retry-After, and understand when an in-memory limiter stops being correct.
Task
Limit requests per client and route, return a clear 429 response, and avoid trusting spoofable proxy headers.
When to use this
A public API receives bursts from a single client. Without a limit, one caller can consume the connection pool and degrade latency for everyone.
Prerequisites
- An HTTP service with access to the client identity
- A reverse proxy that sets a trusted forwarding header
- A decision on limits per route and per client
Steps
1. Implement a fixed-window limiter keyed by client and route, with a cap on the map size and cleanup of expired entries.
export function rateLimit({ windowMs = 60_000, max = 60 } = {}) {
const buckets = new Map();
return (req, res, next) => {
const now = Date.now();
const key = `${req.ip}:${req.route?.path ?? req.path}`;
const current = buckets.get(key);
if (!current || current.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return next();
}
if (current.count >= max) {
const retryAfter = Math.ceil((current.resetAt - now) / 1000);
res.setHeader('Retry-After', String(retryAfter));
return res.status(429).json({ error: 'rate limit exceeded' });
}
current.count += 1;
next();
};
}
app.use(rateLimit({ windowMs: 60_000, max: 60 }));
Expected output: The 61st request in a window receives 429 with a Retry-After header.
2. Configure the proxy trust setting so req.ip is derived from a trusted forwarding header, not a client-supplied one.
app.set('trust proxy', 1);
Expected output: req.ip reflects the client address appended by the trusted proxy.
3. Send a burst and verify the status distribution and Retry-After value.
for i in $(seq 1 70); do curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/api/items; done | sort | uniq -cExpected output: The first 60 requests return 200 and the rest return 429.
Troubleshooting
Problem
Every client shares one bucket
Solution
req.ip is the proxy address because trust proxy is not configured. Set it to the correct number of trusted hops.
Problem
The limit resets too early
Solution
Check the window boundary logic and the clock source; fixed windows allow bursts at the boundary by design.
Limitations
- An in-memory limiter is per process and resets on restart; use Redis or a gateway for a global limit.
- Fixed windows allow up to twice the limit across a boundary; use a sliding window or token bucket for stricter behavior.
A rate limit is a policy, not a number
Choose the dimension first: per IP, per API key, per user, or per tenant. Per-IP limits are easy but punish users behind a shared NAT. Per-key limits require authentication and key rotation. Apply stricter limits to expensive or unauthenticated routes, and keep health checks out of the limiter.
In-memory is a starting point
The Map-based limiter is correct for one process. With multiple instances behind a load balancer, each instance has its own count, so a client can receive N times the intended limit. Move the counter to Redis or enforce the limit at the gateway when the service scales horizontally.
Cleanup matters. A Map keyed by IP can grow without bound under scanning traffic. Sweep expired buckets periodically, cap the map size, and consider a bounded LRU. A limiter that leaks memory under attack is a new vulnerability.
Respond usefully
Return 429, not 500. Include Retry-After and a short body. Do not reveal internal quotas or other clients’ information. Log the limit key and route at a sampled rate, but avoid logging raw IP addresses indefinitely if privacy rules require minimization.
app.use(rateLimit({ windowMs: 60_000, max: 60 }));
Verify the boundary
Expected outcome: exactly 60 requests in the window succeed and the 61st returns 429 with a positive Retry-After value. Confirm by running the burst command from the steps and by checking that the counter is keyed by the client address rather than the proxy address. A limit that never triggers, or triggers for every client at once, indicates a key or trust-proxy bug.