Technology

Building a Sliding-Window Rate Limiter for Node.js APIs with Redis

Advanced 16 min read VisTechie Team Technology
1 views

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.

1 views

Comments

0/2000

Comments are reviewed before being published.

Loading comments...

Related Articles

Building a Sliding-Window Rate Limiter for Node.js APIs with Redis - VisTechie | VisTechie