A public API without rate limiting is an open invitation — to abuse, to runaway clients, to DDoS vectors disguised as legitimate traffic. If you have shipped a Node.js API and have not yet implemented rate limiting, this is the gap that will eventually cost you.

This guide skips the toy examples. You will implement a sliding-window rate limiter backed by Redis, wired into an Express middleware layer, ready for a production environment.


Why Sliding Window Instead of Fixed Window?

The two most common strategies are fixed-window and sliding-window.

A fixed-window limiter resets a counter every N seconds. The problem: a client can fire 100 requests at 11:59:59, wait one second, then fire another 100 at 12:00:00 — effectively doubling throughput at the boundary.

A sliding-window limiter tracks request timestamps over a rolling time range. If your limit is 100 requests per minute, only the last 60 seconds ever count — no matter when the clock ticks over. This is far more resilient and fair.


Prerequisites

  • Node.js 18+
  • Redis 6+ (local or managed — Redis Cloud, Upstash, or AWS ElastiCache all work)
  • Express 4+
  • ioredis npm package
npm install express ioredis

The Core Algorithm

For each incoming request, you:

  1. Record the current timestamp in a Redis sorted set keyed to the client's identifier (usually IP or API key).
  2. Remove all entries older than the window (e.g., 60 seconds ago).
  3. Count the remaining entries.
  4. If the count exceeds the limit, reject with 429 Too Many Requests.
  5. Otherwise, add the current timestamp and allow the request through.

Redis sorted sets are perfect here — the score is the timestamp, range queries are O(log N), and atomic Lua scripts prevent race conditions.


Implementation

1. Redis Client Setup

// redis.js
import Redis from "ioredis";

const redis = new Redis({
  host: process.env.REDIS_HOST || "127.0.0.1",
  port: parseInt(process.env.REDIS_PORT || "6379"),
  password: process.env.REDIS_PASSWORD || undefined,
  tls: process.env.REDIS_TLS === "true" ? {} : undefined,
});

redis.on("error", (err) => console.error("[Redis]", err));

export default redis;

Always externalise credentials via environment variables. Enable TLS when connecting to managed Redis instances in production.


2. The Sliding-Window Middleware

// rateLimiter.js
import redis from "./redis.js";

const WINDOW_SECONDS = 60;
const MAX_REQUESTS = 100;

const slidingWindowScript = `
  local key = KEYS[1]
  local now = tonumber(ARGV[1])
  local window = tonumber(ARGV[2])
  local limit = tonumber(ARGV[3])

  -- Remove timestamps outside the current window
  redis.call("ZREMRANGEBYSCORE", key, 0, now - window * 1000)

  local count = redis.call("ZCARD", key)

  if count >= limit then
    return 0
  end

  -- Add current timestamp as both score and member (unique via nonce)
  redis.call("ZADD", key, now, now .. "-" .. math.random(1e9))
  redis.call("EXPIRE", key, window)

  return 1
`;

export function rateLimiter(options = {}) {
  const window = options.windowSeconds || WINDOW_SECONDS;
  const limit = options.maxRequests || MAX_REQUESTS;

  return async (req, res, next) => {
    const identifier =
      req.headers["x-api-key"] || req.ip || "anonymous";
    const key = `rl:${identifier}`;
    const now = Date.now();

    try {
      const allowed = await redis.eval(
        slidingWindowScript,
        1,
        key,
        now,
        window,
        limit
      );

      res.set("X-RateLimit-Limit", limit);
      res.set("Retry-After", allowed ? undefined : window);

      if (!allowed) {
        return res.status(429).json({
          error: "Too many requests. Please slow down.",
        });
      }

      next();
    } catch (err) {
      console.error("[RateLimiter] Redis error:", err.message);
      // Fail open — do not block users if Redis is temporarily unavailable
      next();
    }
  };
}

A few deliberate choices worth noting:

  • Lua script atomicity: The entire check-and-write executes as a single atomic operation in Redis. No race condition between the count check and the insert.
  • Fail open: If Redis goes down, requests are allowed through. Failing closed (blocking all traffic) is the wrong default for most APIs — it turns an infrastructure incident into a customer-facing outage. Log aggressively and alert instead.
  • Member uniqueness: Timestamps can collide under high concurrency. Appending a random nonce ensures each entry is unique in the sorted set.

3. Wiring It Into Express

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

const app = express();
app.use(express.json());

// Global limiter: 100 requests per 60 seconds per client
app.use(rateLimiter());

// Stricter limiter for auth endpoints
app.use(
  "/api/auth",
  rateLimiter({ windowSeconds: 60, maxRequests: 10 })
);

app.get("/api/data", (req, res) => {
  res.json({ message: "Success" });
});

app.listen(3000);

Apply tighter limits to sensitive endpoints — login, password reset, OTP verification. A brute-force attempt on /api/auth with a 10-request cap is meaningfully harder than one with a 100-request global cap.


Edge Cases You Must Handle

1. IPv6 and proxies Behind a load balancer, req.ip will be your internal load balancer's IP, not the client's. Set app.set("trust proxy", 1) and ensure your proxy forwards X-Forwarded-For correctly.

2. API key vs IP limiting Prefer API key-based identification where possible. IP-based limiting breaks for clients behind NAT or shared office networks, where dozens of users share a single egress IP.

3. Distributed deployments Because Redis is the shared state store, this limiter works correctly across multiple Node.js instances or containers. No sticky sessions or in-memory counters needed.

4. Key expiry The EXPIRE call ensures sorted set keys are cleaned up after inactivity. Without it, Redis memory grows unbounded as client identifiers accumulate.


Deployment Checklist

  • Use a dedicated Redis instance for rate limiting — separate from your cache or session store. Load profiles differ.
  • Set maxmemory-policy to allkeys-lru or volatile-lru so Redis does not crash under memory pressure.
  • Monitor the X-RateLimit-Limit and Retry-After headers from the client side to build respectful retry logic.
  • Consider exposing a /health endpoint that bypasses rate limiting for uptime monitors.
  • Alert on Redis connection errors — a silent Redis failure with fail-open logic means your limiter is not running.

Why This Matters for Your Project

Rate limiting is not a nice-to-have — it is a baseline reliability and security control. Whether you are shipping a SaaS API, a mobile backend, or a third-party integration layer, uncontrolled traffic will eventually break your service or your bill. A Redis-backed sliding-window limiter is cheap to operate, straightforward to reason about, and scales horizontally without code changes. Build it early, tune the thresholds as you learn your traffic patterns, and your API will be meaningfully more resilient from day one.