Why the naive approach breaks at the edges
The simplest rate limiter counts requests in the current minute and resets the counter when the minute rolls over. It's easy to write and it's wrong in a way that only shows up under real traffic: a client can send its full quota in the last second of one window, then immediately send another full quota in the first second of the next window. That's double the intended rate, sustained right across the boundary, and it's exactly the kind of burst that takes down an under-provisioned downstream service.
// Fixed window - simple, but bursts at the window edge
async function fixedWindowAllow(key, limit) {
const windowKey = `rl:${key}:${Math.floor(Date.now() / 60000)}`;
const count = await redis.incr(windowKey);
if (count === 1) await redis.expire(windowKey, 60);
return count <= limit;
}
The fix: a sliding window built from two counters
You don't need a full sliding log of every request timestamp to fix this - that gets expensive in memory fast. A sliding-window counter approximates it well using just the current and previous fixed windows, weighted by how far into the current window you are.
async function slidingWindowAllow(key, limit, windowSeconds = 60) {
const now = Date.now();
const currentWindow = Math.floor(now / (windowSeconds * 1000));
const elapsedMs = now - currentWindow * windowSeconds * 1000;
const weight = 1 - elapsedMs / (windowSeconds * 1000);
const currentKey = `rl:${key}:${currentWindow}`;
const prevKey = `rl:${key}:${currentWindow - 1}`;
const [currentCount, prevCount] = await Promise.all([
redis.get(currentKey),
redis.get(prevKey),
]);
const estimated =
(Number(prevCount) || 0) * weight + (Number(currentCount) || 0);
if (estimated >= limit) return false;
const multi = redis.multi();
multi.incr(currentKey);
multi.expire(currentKey, windowSeconds * 2);
await multi.exec();
return true;
}
The weighting is what does the real work: early in the current window, the previous window still counts heavily toward the estimate, which is exactly what stops the burst-at-the-boundary problem. By the time you're most of the way through the current window, the previous window's contribution has faded out almost entirely, so the limiter naturally converges back to something close to a plain per-window count.
Making the increment atomic
There's a subtle race in the version above: the read (checking estimated) and the write (incrementing the counter) aren't atomic, so two concurrent requests can both read a count just under the limit and both get allowed through. Under real concurrency, this is exactly the kind of bug that only shows up under load testing. A Lua script executed via EVAL closes that gap, because Redis runs it as a single atomic operation.
const script = `
local current = redis.call("GET", KEYS[1]) or 0
local prev = redis.call("GET", KEYS[2]) or 0
local weight = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local estimated = (tonumber(prev) * weight) + tonumber(current)
if estimated >= limit then
return 0
end
redis.call("INCR", KEYS[1])
redis.call("EXPIRE", KEYS[1], ARGV[3])
return 1
`;
async function slidingWindowAllowAtomic(currentKey, prevKey, weight, limit, ttl) {
const result = await redis.eval(script, {
keys: [currentKey, prevKey],
arguments: [String(weight), String(limit), String(ttl)],
});
return result === 1;
}
Wiring it into Express middleware
function rateLimiter({ limit = 100, windowSeconds = 60 } = {}) {
return async function (req, res, next) {
const key = req.user?.id || req.ip;
const allowed = await slidingWindowAllow(key, limit, windowSeconds);
if (!allowed) {
return res.status(429).json({
error: "Too many requests. Please slow down.",
});
}
next();
};
}
app.use("/api/", rateLimiter({ limit: 100, windowSeconds: 60 }));
One thing worth deciding deliberately
Rate limiting per user ID versus per IP address is a real tradeoff, not just an implementation detail. Per-IP protects you against unauthenticated abuse but punishes every user behind a shared corporate NAT or a mobile carrier's IP pool equally. Per-user is fairer once someone's authenticated, but does nothing for anonymous endpoints like login or signup, which are exactly the endpoints attackers hit hardest. Most production APIs end up running both - per-IP on public, unauthenticated routes, per-user everywhere behind auth.
Comments
Loading comments...
Related Articles
Taming Slow MongoDB Aggregation Pipelines in Production
An aggregation pipeline that returns instantly on your 500-document local dataset can crawl once it hits a few million real documents. Here's how to actually find and fix the stage that's costing you.
Read ArticleMulti-Tenant Data Isolation: Shared Schema vs Separate Databases
Every multi-tenant SaaS eventually has to answer one question honestly: how do you stop one customer from ever seeing another customer's data? Here's how the three common isolation strategies actually compare in practice.
Read ArticleGetting Started with NestJS if You Already Know Express
NestJS looks like a lot of ceremony at first glance. Once you see how modules, controllers, and providers map onto things you already do in Express, it clicks fast.
Read Article