All code helpers
BackendTypeScript

Redis sliding-window rate limiter

Limit requests per user, API key or IP across every instance of your service. A single Lua script keeps the check-and-increment atomic, so concurrent requests can't slip past the limit.

#redis#rate-limit#ioredis#lua
lib/rate-limit.ts
import { randomUUID } from "node:crypto";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");

// Sliding-window log: one sorted-set member per request, scored by time in ms.
// Uses Redis TIME so app servers with drifting clocks agree on "now".
const SCRIPT = `
local key    = KEYS[1]
local window = tonumber(ARGV[1])
local limit  = tonumber(ARGV[2])
local t      = redis.call("TIME")
local now    = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)

if count < limit then
  redis.call("ZADD", key, now, ARGV[3])
  redis.call("PEXPIRE", key, window)
  return {1, limit - count - 1, 0}
end

local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
return {0, 0, tonumber(oldest[2]) + window - now}
`;

export type RateLimitResult = {
  allowed: boolean;
  remaining: number;
  retryAfterMs: number;
};

export async function rateLimit(
  id: string,
  limit: number,
  windowMs: number,
): Promise<RateLimitResult> {
  const [allowed, remaining, retryAfterMs] = (await redis.eval(
    SCRIPT,
    1,
    `ratelimit:${id}`,
    windowMs,
    limit,
    randomUUID(),
  )) as [number, number, number];

  return { allowed: allowed === 1, remaining, retryAfterMs: Math.max(0, retryAfterMs) };
}

Usage

TypeScript
import type { NextFunction, Request, Response } from "express";
import { rateLimit } from "./lib/rate-limit";

export async function limitByIp(req: Request, res: Response, next: NextFunction) {
  const result = await rateLimit(`ip:${req.ip}`, 100, 60_000); // 100 req/min
  res.setHeader("X-RateLimit-Remaining", result.remaining);
  if (!result.allowed) {
    res.setHeader("Retry-After", Math.ceil(result.retryAfterMs / 1000));
    return res.status(429).json({ error: "Too many requests" });
  }
  next();
}

How it works & gotchas

  • 01A sliding log is exact: there is no burst at the window edge like with fixed windows. The cost is one sorted-set entry per request, so it suits limits in the hundreds, not millions.
  • 02For very high limits, use a fixed-window counter (INCR + PEXPIRE) or a token bucket. Both store a constant amount per key.
  • 03Rejected requests are not recorded, so a client hammering the API recovers as soon as old entries age out.
  • 04Behind a proxy, req.ip is the proxy's address unless you configure Express trust proxy. Limiting on the wrong IP throttles everyone at once.
  • 05Decide what happens when Redis is down. Failing open keeps the API up; failing closed protects expensive endpoints.

Need this wired into a real system?

We build and harden production software end to end.

Talk to an engineer