pagefault
  1. Guides
  2. Node.js
  3. Rate limiting

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.

Code editor open on a laptop screen
Tested on Node 20.11, Redis 7.2 and Express 4.19.
Level
Intermediate
Time
35 minutes
Stack
Node, Express, Redis
Updated

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 , so bursts at the boundary no longer double the limit.

Set up the project5 min

Start from an empty folder. The only runtime dependencies are Express and ioredis, which pipelines commands and handles reconnects for us.

Terminalbash
$ mkdir ratelimit-demo
$ cd ratelimit-demo
$ npm init -y
$ npm install express ioredis

Connect to Redis5 min

Create 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.

src/redis.jsjs
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);
});

Count requests in a window10 min

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.

src/limiter.jsjs
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) };
}

Add the middleware10 min

Wrap the limiter in middleware that sets the remaining budget on every response and stops the request with a 429 once it is spent.

src/middleware.jsjs
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 }));

Test the limit5 min

Start the server and fire 62 requests. The first 60 should pass and the last two should be rejected.

Terminalbash
$ 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 429

That 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?

We read every reply and fix guides weekly.