Build a sliding-window rate limiter with Redis
Protect an Express API from bursts with one sorted set per client, a single round trip and honest RateLimit headers.
- Level
- Intermediate
- Time
- 35 minutes
- Stack
- Node, Express, Redis
- Updated
Protect an Express API from bursts with one sorted set per client, a single round trip and honest RateLimit headers.
Fixed windows are easy to write and easy to fool: a client can send a full quota at 11:59:59 and another at 12:00:00. A sliding window counts the last sixty seconds from now, so bursts at the boundary no longer double the limit.
Start from an empty folder. The only runtime dependencies are Express and ioredis, which pipelines commands and handles reconnects for us.
$ mkdir ratelimit-demo
$ cd ratelimit-demo
$ npm init -y
$ npm install express ioredisCreate one shared client. Turning off the offline queue matters here: if Redis goes away we want calls to fail fast instead of piling up behind a reconnect.
import Redis from 'ioredis';
export const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
maxRetriesPerRequest: 2,
enableOfflineQueue: false,
});
redis.on('error', (err) => {
console.error('[redis]', err.message);
});Each client gets a sorted set whose scores are request timestamps. On every hit we trim entries older than the window, add the current request, count what is left and refresh the expiry. MULTI sends all four commands in one round trip.
import { redis } from './redis.js';
export async function hit(key, { limit = 60, windowMs = 60000 } = {}) {
const now = Date.now();
const member = now + '-' + Math.random().toString(36).slice(2, 8);
const results = await redis
.multi()
.zremrangebyscore(key, 0, now - windowMs)
.zadd(key, now, member)
.zcard(key)
.pexpire(key, windowMs)
.exec();
const count = results[2][1];
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}Wrap the limiter in middleware that sets the remaining budget on every response and stops the request with a 429 once it is spent.
import { hit } from './limiter.js';
export function rateLimit(options) {
return async (req, res, next) => {
try {
const { allowed, remaining } = await hit('rl:' + req.ip, options);
res.set('RateLimit-Remaining', String(remaining));
if (!allowed) return res.status(429).json({ error: 'Too many requests' });
next();
} catch (err) {
next(); // fail open when Redis is unreachable
}
};
}Then mount it in front of the routes you want to protect:
app.use('/api', rateLimit({ limit: 60, windowMs: 60000 }));Start the server and fire 62 requests. The first 60 should pass and the last two should be rejected.
$ for i in $(seq 1 62); do curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api; done | uniq -c
60 200
2 429That is the whole limiter. The full example, with tests and a Docker Compose file for Redis, lives in the companion repository.
Did this guide work for you?