Rate limiting is one of those backend concerns that gets copy-pasted from Stack Overflow until it breaks in production. When a SaaS product starts attracting real traffic — and real abuse — a black-box library is the last thing you want protecting your API. Building one yourself, even once, gives you the mental model to tune thresholds, debug edge cases, and design around your actual usage patterns.

This tutorial builds a sliding-window rate limiter using Redis sorted sets in Node.js. No libraries. No magic. Just Redis primitives and about 40 lines of logic.


Why Sliding Window Over Fixed Window?

The simpler approach — fixed window — counts requests in a fixed time bucket (e.g., 100 requests per minute, reset at the top of every minute). The problem: a client can fire 100 requests at 12:00:59 and another 100 at 12:01:01, bursting 200 requests in two seconds without technically violating the rule.

A sliding window tracks requests relative to now, not the bucket boundary. At any given moment, you look back exactly one window (say, 60 seconds) and count how many requests occurred in that interval. The window slides with time — hence the name — which eliminates the boundary burst problem entirely.


Why Redis Sorted Sets?

Redis sorted sets store members with a floating-point score. The trick: use the current timestamp (in milliseconds) as the score for each incoming request. This gives you:

  • O(log N) inserts via ZADD
  • O(log N + M) range deletions via ZREMRANGEBYSCORE to evict old entries
  • O(1) counts via ZCARD

Each unique client (by IP, API key, or user ID) gets its own sorted set key. The set acts as a rolling log of request timestamps.


The Implementation

Prerequisites

npm install ioredis

You need Redis running locally (redis-server) or a managed instance (Redis Cloud, Upstash, etc.).

Core Rate Limiter Function

const Redis = require("ioredis");
const redis = new Redis(); // defaults to 127.0.0.1:6379

/**
 * Sliding-window rate limiter using Redis sorted sets.
 * @param {string} clientId  - Unique identifier (IP, API key, user ID)
 * @param {number} limit     - Max requests allowed in the window
 * @param {number} windowMs  - Window size in milliseconds
 * @returns {Promise<{ allowed: boolean, remaining: number, retryAfter: number }>}
 */
async function isAllowed(clientId, limit, windowMs) {
  const key = `rate:${clientId}`;
  const now = Date.now();
  const windowStart = now - windowMs;

  // Use a pipeline to batch all Redis commands in one round-trip
  const pipeline = redis.pipeline();

  // 1. Remove entries older than the current window
  pipeline.zremrangebyscore(key, 0, windowStart);

  // 2. Add the current request with its timestamp as score
  pipeline.zadd(key, now, `${now}-${Math.random()}`);

  // 3. Count all entries currently in the window
  pipeline.zcard(key);

  // 4. Set TTL so keys self-clean (window + 1s buffer)
  pipeline.pexpire(key, windowMs + 1000);

  const results = await pipeline.exec();

  // zcard result is at index 2
  const requestCount = results[2][1];
  const allowed = requestCount <= limit;
  const remaining = Math.max(0, limit - requestCount);

  // If blocked, tell the client how long to wait
  let retryAfter = 0;
  if (!allowed) {
    const oldest = await redis.zrange(key, 0, 0, "WITHSCORES");
    const oldestTimestamp = parseInt(oldest[1], 10);
    retryAfter = Math.ceil((oldestTimestamp + windowMs - now) / 1000);
  }

  return { allowed, remaining, retryAfter };
}

Plugging It Into an Express Middleware

async function rateLimitMiddleware(req, res, next) {
  const clientId = req.headers["x-api-key"] || req.ip;
  const { allowed, remaining, retryAfter } = await isAllowed(clientId, 100, 60_000);

  res.set("X-RateLimit-Remaining", remaining);

  if (!allowed) {
    res.set("Retry-After", retryAfter);
    return res.status(429).json({
      error: "Too Many Requests",
      retryAfter,
    });
  }

  next();
}

Tuning for Real SaaS Abuse Scenarios

A single global limit rarely fits a real product. Here is how to layer the logic:

Per-Tier Limits

Free-tier users get 60 requests/minute; paid users get 600. Pass the limit dynamically based on the authenticated user's plan, not a hardcoded constant. The isAllowed function already accepts limit as a parameter — wire it to your billing data.

Endpoint-Level Granularity

Your /auth/login endpoint is far more abuse-prone than /api/user/profile. Use a composite key:

const clientId = `${req.ip}:${req.path}`;

This gives each endpoint its own window without any additional infrastructure.

Distributed Burst Protection

For very high-traffic APIs, add a second short-window check alongside the main one — for example, no more than 20 requests in any 5-second span, and no more than 100 in 60 seconds. Call isAllowed twice with different parameters and reject if either fails.

Token Bucket vs. Sliding Window

If you need to allow occasional bursts (a legitimate client uploading a batch of files), a token bucket model is more permissive. The sliding window presented here is stricter and better suited for abuse prevention. Know which problem you are actually solving before choosing.


Operational Considerations

Memory: Each request entry is a sorted set member. For 1,000 active clients each firing 100 requests/minute, that is 100,000 entries in Redis — trivial by Redis standards. Still, set the TTL (pexpire) correctly or stale keys accumulate.

Atomicity: The pipeline used above is not atomic in the strict sense — it batches round-trips but does not use a Lua script or transaction. Under extreme concurrency, two near-simultaneous requests could both read a count of 99 and both be allowed before the set reaches 101. For most SaaS workloads this is acceptable. If you need hard limits, move the logic into a Redis Lua script (EVAL) to guarantee atomic execution.

Redis failures: Always fail open (allow the request) rather than fail closed (block everything) when Redis is unreachable — unless your threat model demands otherwise. Silently degrading to pass-through is better than an outage.


Why This Matters for Your Project

Rate limiting sits at the intersection of backend security, infrastructure cost, and user experience. Getting it wrong means either letting abuse drain your compute budget or throttling legitimate users into frustration. Building your own sliding-window limiter — even if you later move to a managed solution — means you understand the levers: window size, limit thresholds, key granularity, and failure behavior. That understanding is what lets you architect a SaaS backend that stays reliable as it scales, rather than one that surprises you the first time a script kiddie or a misconfigured client hammers your endpoints.