A public API without rate limiting is an open invitation — to abuse, to runaway billing, and to cascading failures that take down your entire service. Yet most tutorials hand you a fixed-window counter and call it a day. Fixed windows have a well-known flaw: a client can fire double the allowed requests by straddling two window boundaries. The sliding-window algorithm closes that gap, and Redis sorted sets make it surprisingly clean to implement.

This article gives you production-ready middleware you can drop into any Express or Fastify app today.

Why Sliding Window Over Fixed Window

Fixed-window counters reset on the clock — say, every 60 seconds. A client that sends 100 requests at 00:59 and another 100 at 01:01 effectively fires 200 requests in two seconds while staying "within limits" for both windows.

A sliding window anchors the time frame to now minus the window duration. Every request is checked against only the requests that actually happened within that rolling period. The math is slightly heavier, but Redis handles it in microseconds.

Why Redis Sorted Sets

Redis sorted sets store members ranked by a numeric score. When you use the current timestamp (in milliseconds) as both the member and the score, you get a self-ordering log of request timestamps. Three operations are all you need:

  • ZREMRANGEBYSCORE — evict entries older than the window.
  • ZCARD — count remaining entries (your current usage).
  • ZADD — record the new request.
  • EXPIRE — garbage-collect the key after inactivity.

All four run atomically inside a Lua script, which means no race conditions even under high concurrency.

Setting Up the Project

npm init -y
npm install express ioredis

Use ioredis over the older redis v3 client — it has better TypeScript types, native promise support, and a clean defineCommand API for Lua scripts.

The Lua Script

Lua scripts execute atomically on the Redis server. This is non-negotiable for a correct rate limiter — without atomicity, two simultaneous requests can both read a count of 99, both decide they are within the limit, and both proceed, giving you 101 requests through a 100-request window.

-- keys[1] = rate limit key (e.g. "rl:user:42")
-- argv[1] = window size in milliseconds
-- argv[2] = max requests allowed in window
-- argv[3] = current timestamp in milliseconds

local key       = KEYS[1]
local window    = tonumber(ARGV[1])
local limit     = tonumber(ARGV[2])
local now       = tonumber(ARGV[3])
local clearBefore = now - window

-- Remove timestamps outside the current window
redis.call("ZREMRANGEBYSCORE", key, "-inf", clearBefore)

-- Count requests in the current window
local count = redis.call("ZCARD", key)

if count < limit then
  -- Record this request; score = timestamp, member = timestamp (unique enough)
  redis.call("ZADD", key, now, now .. "-" .. math.random(1, 1e9))
  redis.call("PEXPIRE", key, window)
  return 1   -- allowed
else
  return 0   -- denied
end

The Express Middleware

// rateLimiter.js
import Redis from "ioredis";
import fs from "fs";
import path from "path";

const redis = new Redis({ host: "127.0.0.1", port: 6379 });

const luaScript = fs.readFileSync(
  path.resolve("rateLimiter.lua"),
  "utf8"
);

/**
 * @param {object} options
 * @param {number} options.windowMs   - Window duration in ms (e.g. 60_000)
 * @param {number} options.max        - Max requests per window
 * @param {function} options.keyFn   - (req) => string key suffix
 */
export function slidingWindowLimiter({ windowMs, max, keyFn }) {
  return async (req, res, next) => {
    const key = `rl:${keyFn(req)}`;
    const now = Date.now();

    const allowed = await redis.eval(
      luaScript, 1, key, windowMs, max, now
    );

    res.setHeader("X-RateLimit-Limit", max);

    if (!allowed) {
      res.setHeader("Retry-After", Math.ceil(windowMs / 1000));
      return res.status(429).json({
        error: "Too Many Requests",
        retryAfter: Math.ceil(windowMs / 1000),
      });
    }

    next();
  };
}

Attach it to any route:

import express from "express";
import { slidingWindowLimiter } from "./rateLimiter.js";

const app = express();

const apiLimiter = slidingWindowLimiter({
  windowMs: 60_000,          // 1 minute
  max: 100,                  // 100 requests per minute
  keyFn: (req) =>            // scope per authenticated user, fall back to IP
    req.user?.id ?? req.ip,
});

app.use("/api/", apiLimiter);

For Fastify, replace the Express middleware signature with a Fastify hook:

fastify.addHook("onRequest", async (request, reply) => {
  // same Redis eval call; throw reply.code(429).send(...) on denial
});

Production Considerations

Key Design

Rate limit by user ID when you have authenticated users, and by IP for public endpoints. For tiered plans, parameterize max from a user record:

keyFn: (req) => `plan:${req.user.plan}:${req.user.id}`

Redis High Availability

A single Redis instance is a single point of failure. For production, use Redis Sentinel or Redis Cluster. If you use Redis Cluster, be aware that Lua scripts with multiple keys only work if all keys hash to the same slot — our single-key script is fine.

Observability

Emit a metric (Prometheus counter, Datadog event) every time a request is denied. Sudden spikes in 429s are early warning signs of a scraper, a misbehaving client, or — occasionally — a bug in your own frontend.

Graceful Degradation

If Redis is unreachable, decide your failure mode upfront. Failing open (allowing all requests) protects user experience but exposes you to abuse. Failing closed (blocking all requests) is safer for sensitive endpoints. Wrap the redis.eval call in a try/catch and route accordingly.

What This Looks Like Under Load

With a 100 req/min limit and a sliding window, a client sending 100 requests between T+0 and T+30 will be blocked until those early entries age out — not until the next clock-aligned minute. The effective rate is smoother, clients get predictable backpressure, and your upstream services see consistent load.

Why This Matters for Your Project

Whether you are building a SaaS API that bills by usage, a mobile backend handling thousands of concurrent users, or an internal microservice that needs to protect a costly ML inference endpoint, a sliding-window rate limiter is table-stakes infrastructure. Getting it right — atomic operations, correct key scoping, observable denials — separates a system that holds up under real traffic from one that quietly breaks when it matters most. The implementation above is small enough to own yourself and robust enough to run in production from day one.