How I Rate Limited My Next.js API With a Sliding Window in Redis
Fixed-window counters let bursts slip through. Here's how I built an accurate, atomic sliding window rate limiter with Redis sorted sets and Lua.
Jason
7 min read·
A few weeks ago I looked at the logs for the contact form on my portfolio and found a single IP address that had submitted it a little over 900 times in an hour. Nothing was broken, but every one of those submissions triggered an email, a database insert and a spam-check call. That was the day I stopped treating rate limiting as a "later" problem.
In this post I'll walk through the rate limiter I ended up with: a sliding window log stored in a Redis sorted set, executed atomically with a small Lua script, and wired into a Next.js route handler. It's about 60 lines of code, it's accurate, and it works across as many server instances as you run.
Why the obvious approach wasn't good enough
My first attempt was the classic fixed window counter. You bucket time into minutes and increment a counter per bucket:
// Fixed window: simple, but bursty at the boundary
const key = `rl:${ip}:${Math.floor(Date.now() / 60_000)}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 60);
if (count > 5) return tooManyRequests();
It's cheap and easy, and for a lot of APIs it's honestly fine. But it has a well-known flaw: the window resets on a hard boundary. A client can send 5 requests at 12:00:59 and another 5 at 12:01:00, which is 10 requests in two seconds against a limit of "5 per minute". For a form that sends email, that burst is exactly what I wanted to stop.
I also ruled out an in-memory limiter (a Map in the Node.js process). The moment you have more than one instance, or serverless functions that spin up and down, each instance keeps its own count and the limit quietly multiplies. The state needs to live somewhere shared, and Redis is the usual answer because it's fast and has data structures that fit this problem well.
The sliding window log idea
Instead of counting per bucket, a sliding window log remembers the timestamp of every accepted request in the last window. On each new request you:
- Delete timestamps older than now - window.
- Count the timestamps that remain.
- If the count is below the limit, record the new timestamp and allow the request; otherwise reject it.
Because the window moves with the clock, there's no boundary to exploit. "5 per minute" really means "at most 5 in any 60-second span."
A Redis sorted set is a natural fit. Each member gets a score, and I use the timestamp in milliseconds as the score. That gives me ZREMRANGEBYSCORE to trim old entries, ZCARD to count, and ZADD to insert, all of which are efficient operations.
Making it atomic with Lua
Here's the trap I nearly fell into. If you run those steps as separate commands from Node.js, two concurrent requests can both read a count of 4, both decide there's room, and both get added. You've let through 6 requests on a limit of 5.
Wrapping the commands in MULTI/EXEC makes them run together, but a transaction can't branch on the result of an earlier command inside it. The common workaround is to add first and count afterwards, but then rejected requests still get recorded, so a client that keeps hammering you never gets back under the limit.
A Lua script solves both problems. Redis runs a script atomically: no other command runs in the middle of it, so check-then-write is safe. Here's the whole thing:
-- KEYS[1] = rate limit key
-- ARGV[1] = now (ms), ARGV[2] = window (ms), ARGV[3] = limit, ARGV[4] = member id
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- 1. Drop every timestamp that has slid out of the window
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
-- 2. Count what is left
local count = redis.call("ZCARD", key)
if count < limit then
-- 3a. Room left: record this request and refresh the TTL
redis.call("ZADD", key, now, ARGV[4])
redis.call("PEXPIRE", key, window)
return {1, limit - count - 1, 0}
end
-- 3b. Full: tell the caller when the oldest entry expires
local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
local retryAfter = tonumber(oldest[2]) + window - now
return {0, 0, retryAfter}
A few details worth pointing out:
- The member must be unique. Sorted set members are unique, so if two requests arrive in the same millisecond and I used the timestamp as the member, the second ZADD would just overwrite the first. I pass a member made from the timestamp plus a UUID.
- PEXPIRE keeps Redis tidy. If a client goes quiet, the whole key expires after one window instead of sitting in memory forever.
- The script returns a retry hint. When the set is full, the oldest entry is the next one to leave the window, so its score plus the window length tells me exactly when the client can try again.
The TypeScript wrapper
I use ioredis, which exposes eval(script, numKeys, ...keysAndArgs). Passing the key through KEYS rather than hard-coding it inside the script matters if you ever move to Redis Cluster, because that's how Redis knows which node the script touches.
// lib/rate-limit.ts
import Redis from "ioredis";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
const redis = new Redis(process.env.REDIS_URL!);
const script = readFileSync(
path.join(process.cwd(), "lib/sliding-window.lua"),
"utf8"
);
export type RateLimitResult = {
allowed: boolean;
remaining: number;
retryAfterMs: number;
};
export async function rateLimit(
identifier: string,
limit: number,
windowMs: number
): Promise<RateLimitResult> {
const key = `rl:${identifier}`;
const now = Date.now();
const [allowed, remaining, retryAfterMs] = (await redis.eval(
script,
1,
key,
now,
windowMs,
limit,
`${now}-${randomUUID()}`
)) as [number, number, number];
return { allowed: allowed === 1, remaining, retryAfterMs };
}
One thing I'd flag: I'm using Date.now() from the app server as the clock. If you run many servers with noticeably drifting clocks, you can call Redis's TIME command inside the script instead so every instance shares one clock. For my setup, with NTP-synced hosts and a one-minute window, app time was accurate enough and easier to test.
Wiring it into a Next.js route handler
With the helper in place, the route handler stays small. I key the limit by route and client IP, return 429 Too Many Requests when it's exceeded, and include a Retry-After header in seconds so well-behaved clients know when to back off.
// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { rateLimit } from "@/lib/rate-limit";
const LIMIT = 5;
const WINDOW_MS = 60_000;
function clientIp(req: Request): string {
// Set by the platform's proxy; take the first (original client) entry
const forwarded = req.headers.get("x-forwarded-for");
return forwarded?.split(",")[0].trim() || "unknown";
}
export async function POST(req: Request) {
const result = await rateLimit(`contact:${clientIp(req)}`, LIMIT, WINDOW_MS);
if (!result.allowed) {
return NextResponse.json(
{ error: "Too many requests. Please try again shortly." },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(result.retryAfterMs / 1000)),
},
}
);
}
// ...validate the body and send the message
return NextResponse.json(
{ ok: true },
{ headers: { "X-RateLimit-Remaining": String(result.remaining) } }
);
}
Two caveats about the IP:
- x-forwarded-for is only trustworthy behind a proxy you control. If your app is directly reachable, anyone can send a fake header and get a fresh limit with every request. On a managed platform the edge proxy sets it for you; on your own server, make sure your reverse proxy overwrites it rather than appending to whatever the client sent.
- IPs are shared. Offices, universities and mobile carriers put many users behind one address. For authenticated endpoints I key on the user ID instead and keep the IP limit as a looser outer layer.
Also note that this runs on the Node.js runtime, because ioredis needs raw TCP sockets. If you want to rate limit in Middleware or on an edge runtime, you'll need an HTTP-based Redis client instead, but the algorithm and the Lua script stay the same.
Trade-offs I accepted
The sliding window log isn't free. It stores one entry per accepted request, so memory grows with the limit: a limit of 5 per minute is trivial, but a limit of 10,000 per hour per user means up to 10,000 members per key. For high-volume API limits I'd switch to a sliding window counter, which keeps just two counters (current and previous window) and estimates the rate with a weighted average, or to a token bucket if I wanted to allow controlled bursts. For a contact form, login endpoint or password-reset flow, where limits are small and accuracy matters, the log is the right tool.
I also had to decide what happens when Redis is down. I chose to fail open on the contact form (wrap the call in try/catch and allow the request) because losing a real message is worse than letting some spam through for a few minutes. On the login endpoint I fail closed, because unlimited password guessing during an outage is a much worse failure. That's a product decision, not a technical one, so make it explicitly per route.
Testing it
The quickest sanity check is a loop from the terminal:
for i in $(seq 1 8); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact
done
I get five 200s followed by three 429s, and after a minute the first slot frees up again. Running ZRANGE rl:contact:unknown 0 -1 WITHSCORES in redis-cli (locally there's no proxy header, so the key falls back to "unknown") shows the timestamps sliding in and out, which made the algorithm click for me far better than any diagram.
Since adding this, that contact form has gone from hundreds of junk submissions a day to a handful, and the abusive client now gets a polite 429 instead of my inbox.
Key takeaways
- Fixed window counters allow up to double the limit in a burst at the window boundary; a sliding window log enforces the limit over any span.
- Keep rate limit state in a shared store like Redis. In-memory limiters break as soon as you run more than one instance.
- Use a Lua script so check-and-record is atomic. Separate commands race, and MULTI can't branch.
- Give sorted set members unique values, and set a TTL so idle keys expire.
- Return 429 with Retry-After, only trust x-forwarded-for behind your own proxy, and decide per route whether to fail open or closed.
Written by Jason
Published October 17, 2026 · Updated Oct 21, 2026