Six pods behind a load balancer, each running express-rate-limit with the default memory store, and a documented limit of 100 requests per minute. The real limit is 600 per minute, and it drifts every time the autoscaler adds a pod. We have found this exact bug in three client codebases, twice after the traffic had already been billed.

The fix is not complicated, but the version most tutorials show is wrong in a way that only surfaces under load: a GET followed by an INCR from two Node processes in the same millisecond reads the same count twice, allows twice, writes twice. That is a time-of-check-to-time-of-use race, and it gets worse precisely when rate limiting matters most.

What follows is the implementation we ship: a sliding window counter, held in Redis, evaluated inside a single Lua script so the read, the decision and the write are one atomic operation.

Illustration for a Node.js and Redis API rate limiter build

One shared counter store is the difference between a limit you can document and a limit that multiplies by your pod count.

Before you start

You need Node.js 20 or newer (we test on 22 LTS), Express 4 or 5, ioredis 5.x, and a Redis 7.x server or a Valkey 8 instance. Redis Cloud's free tier is 30 MB, which is plenty: the design below stores two small integers per client per window. Docker is optional but makes step 1 a one liner. Budget about 45 minutes end to end, plus another 20 if you want the load test at the end.

One decision to make now: which Redis. Do not put limiter keys on an instance that runs maxmemory-policy allkeys-lru. Under memory pressure Redis will evict a live limiter counter, and a client sitting at 99 of 100 silently gets a fresh allowance. Use a separate database, or a separate small instance with noeviction.

1. Start Redis and confirm you can reach it

docker run -d --name rl-redis -p 6379:6379 redis:7.4-alpine \
  redis-server --save "" --appendonly no --maxmemory-policy noeviction

redis-cli -h 127.0.0.1 ping

You should see PONG. Persistence is off on purpose: losing counters on restart is acceptable for a limiter, and fsync latency has no place in the request path. Install the client:

npm i ioredis express

2. Write the sliding window counter as a Lua script

The algorithm keeps two fixed window counters, current and previous, and blends them by how far into the current window you are. At 30 percent into the window, the previous window still contributes 70 percent of its count. Boundary bursts get smoothed without storing a timestamp per request, which is what the sliding window log does: a limit of 10,000 per hour means up to 10,000 sorted set members per client.

Save this as sliding-window.lua:

-- KEYS[1] current window counter, KEYS[2] previous window counter
-- ARGV: limit, windowSeconds, elapsedFraction (0..1), cost
local limit   = tonumber(ARGV[1])
local window  = tonumber(ARGV[2])
local elapsed = tonumber(ARGV[3])
local cost    = tonumber(ARGV[4])

local curr = tonumber(redis.call('GET', KEYS[1])) or 0
local prev = tonumber(redis.call('GET', KEYS[2])) or 0
local estimate = prev * (1 - elapsed) + curr
local resetIn  = math.ceil((1 - elapsed) * window)

if estimate + cost > limit then
  return { 0, 0, resetIn }
end

local total = redis.call('INCRBY', KEYS[1], cost)
if total == cost then
  redis.call('EXPIRE', KEYS[1], window * 2)
end

return { 1, math.floor(limit - (estimate + cost)), resetIn }

Three details are easy to get wrong. The TTL is twice the window, because the previous counter must still exist while the current window is served. The script checks before it increments, so a rejected request does not inflate the counter and extend its own block. The timestamp comes from the caller rather than Redis TIME, which keeps the function deterministic and testable.

3. Wire it into Express middleware

ioredis will register the script for you with defineCommand, send it as EVALSHA, and fall back to a full EVAL if Redis reports NOSCRIPT. Note the hash tag around the key base: both keys must land on the same cluster slot or a multi key script fails outright.

import fs from 'node:fs';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL, {
  enableOfflineQueue: false,
  maxRetriesPerRequest: 1,
  connectTimeout: 300,
});

redis.defineCommand('slidingWindow', {
  numberOfKeys: 2,
  lua: fs.readFileSync('./sliding-window.lua', 'utf8'),
});

export function rateLimit({ limit, windowSeconds, keyFn, cost = 1 }) {
  const windowMs = windowSeconds * 1000;

  return async function (req, res, next) {
    const now = Date.now();
    const win = Math.floor(now / windowMs);
    const base = `{rl:${keyFn(req)}:${windowSeconds}}`;

    try {
      const [ok, remaining, resetIn] = await redis.slidingWindow(
        `${base}:${win}`,
        `${base}:${win - 1}`,
        limit,
        windowSeconds,
        ((now % windowMs) / windowMs).toFixed(6),
        cost,
      );

      res.set({
        'RateLimit-Limit': limit,
        'RateLimit-Remaining': remaining,
        'RateLimit-Reset': resetIn,
      });

      if (!ok) {
        res.set('Retry-After', resetIn);
        return res.status(429).json({ error: 'rate_limited', retryAfter: resetIn });
      }
      return next();
    } catch (err) {
      return next(err);
    }
  };
}

Mount it per route, with a cost that reflects what the endpoint actually does:

app.post('/v1/reports',
  rateLimit({ limit: 60, windowSeconds: 60, cost: 10, keyFn: r => r.apiKeyId }),
  createReport);

Working correctly looks like this: fire 70 requests and the first 6 succeed, the rest return 429 with Retry-After counting down, and redis-cli --scan --pattern '{rl:*' shows two keys for that client.

4. Decide what happens when Redis is unreachable

This is the step teams skip, and it is the one that causes the outage. With enableOfflineQueue: false a dropped connection rejects immediately instead of queueing until the socket recovers. Then choose deliberately.

Fail open means a Redis blip lifts all limits and your origin takes the flood. Fail closed means a Redis blip returns 429 to every paying customer. Neither works alone, so we do both: catch the error, fall back to a per process counter set to roughly limit / instanceCount, and emit a metric so the degraded state is visible on a dashboard.

rate-limiter-flexible project logo

If you would rather not own this code, rate-limiter-flexible already implements the atomic counters, the in memory block strategy and an insurance store for exactly this failure mode.

Its published figures put an average check at well under a millisecond in cluster mode and a couple of milliseconds against a remote store. We still hand write the script when a client needs weighted costs, per plan tiers and a specific 429 body, because the middleware is forty lines and the debugging surface stays ours.

5. Choose the key, not just the number

Keying by IP is the default in almost every example and it is close to useless for a SaaS API. Mobile carriers and corporate NAT put thousands of unrelated users behind one address, so an IP limit either blocks a whole office or is set so high it stops nothing. Key on the API key or tenant ID. Keep a looser IP limit only on unauthenticated routes such as login and password reset, where you also want a longer block after repeated failures.

Layer the limits. A per tenant limit protects fairness between customers, a per endpoint limit protects the expensive handler, and a global concurrency cap protects the database. Read the plan tier from the same Redis you are already talking to and pass limit in from there, so raising a customer's ceiling is a data change rather than a deploy.

When it does not work

Every request returns 429 after a deploy to Redis Cluster. The error is CROSSSLOT Keys in request don't hash to the same slot. Your key base lost its hash tag braces, usually to a well meaning refactor that "cleaned up" the template string. The braces are the whole mechanism.

Counts are wildly off for a few clients. Check clock skew across your instances. The elapsed fraction is computed from Date.now(), so a node that is two seconds ahead weights the previous window differently. Confirm NTP is running everywhere; if drift is chronic, fetch the time from Redis once and hold an offset.

Latency jumps at p99 after adding the limiter. Each check is a network round trip. Put Redis in the same availability zone as the API, reuse one client instance instead of creating one per request, and never run the script against a cross region replica. Long Lua scripts block the Redis event loop for every other client, which is why this one makes only four calls.

Once it holds, the interesting work is observability. Chart 429s by tenant and by route, alert when a single key crosses half its allowance in the first ten seconds of a window, and put the remaining count in your API docs. Customers who can see the ceiling design their retries around it, and support tickets that used to read "your API is broken" start arriving as "we need a higher tier".