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.
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
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.ipis the proxy's address unless you configure Expresstrust 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.
More code helpers
Verify webhook HMAC signatures
Confirm that an incoming webhook was sent by the provider and wasn't replayed. Uses a constant-time comparison and a timestamp tolerance, the same scheme Stripe and many others use.
Graceful shutdown for Node.js HTTP servers
Finish in-flight requests and close database connections before the process exits, so deploys and autoscaling don't drop requests or leave half-written work.
Stream LLM responses from a Next.js route handler
Send tokens to the browser as the model produces them instead of making users wait for the full completion. Uses a plain ReadableStream, so it works with any client that can read a fetch body.