How to Implement Rate Limiting in Express.js with Redis and Sliding Window Algorithm

How to Implement Rate Limiting in Express.js with Redis and Sliding Window Algorithm

by | Aug 14, 2026 | Uncategorized | 0 comments

Why Rate Limiting in Express.js Matters

If you run a public API, sooner or later you will face abusive traffic: credential stuffing bots, scrapers, or a buggy client hammering your endpoints in a loop. Rate limiting in Express.js is the first line of defense that keeps your infrastructure healthy, your database responsive, and your bill under control.

Most tutorials stop at plugging in express-rate-limit with its default in-memory store. That works fine for a single Node.js process, but it collapses the moment you scale horizontally across multiple containers or pods. In this hands-on guide, we will build a production-ready rate limiter using Redis and a sliding window counter algorithm, and we will discuss the edge cases nobody talks about.

server traffic control

Fixed Window vs Sliding Window: Which One Should You Pick?

Before writing code, let’s understand what we are building and why. The two most common algorithms are fixed window and sliding window.

Criteria Fixed Window Sliding Window
Implementation complexity Very simple Moderate
Memory footprint Low (one counter) Higher (list of timestamps)
Burst at window edges Yes, up to 2x the limit No
Fairness Poor Excellent
Best for Simple internal APIs Public production APIs

The classic fixed window problem: if your limit is 100 requests per minute, a client can send 100 requests at 12:00:59 and another 100 at 12:01:00, effectively doing 200 requests in one second. The sliding window fixes this by considering the last N seconds continuously, no matter where in the clock you are. borntodev.com has a solid rundown on this.

Setting Up the Project

We will use Express 5, Node.js 22 LTS, and Redis 7. Start with a fresh project:

mkdir express-rate-limiter
cd express-rate-limiter
npm init -y
npm install express ioredis
npm install -D nodemon

Make sure Redis is running locally. If you use Docker:

docker run -d --name redis-limiter -p 6379:6379 redis:7-alpine

Building the Sliding Window Rate Limiter

The core idea of the sliding window log approach is to store each request timestamp in a Redis sorted set, then count how many timestamps fall within the current window.

Step 1: Create the Redis client

// redis.js
import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: process.env.REDIS_PORT || 6379,
  enableOfflineQueue: false,
  maxRetriesPerRequest: 2,
});

redis.on('error', (err) => console.error('Redis error:', err.message));

export default redis;

Step 2: Write the sliding window middleware

To make the operation atomic and avoid race conditions between concurrent requests, we use a Lua script. This is critical when your API runs on multiple instances hitting the same Redis.

// rateLimiter.js
import redis from './redis.js';

const SLIDING_WINDOW_SCRIPT = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]

-- Remove entries older than the window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- Count current requests in the window
local count = redis.call('ZCARD', key)

if count < limit then
  redis.call('ZADD', key, now, member)
  redis.call('PEXPIRE', key, window)
  return {1, limit - count - 1}
else
  local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
  local retryAfter = 0
  if oldest[2] then
    retryAfter = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
  end
  return {0, retryAfter}
end
`;

export function slidingWindowLimiter({
  windowMs = 60_000,
  max = 100,
  keyGenerator = (req) => req.ip,
  prefix = 'rl:',
} = {}) {
  return async function (req, res, next) {
    try {
      const key = prefix + keyGenerator(req);
      const now = Date.now();
      const member = `${now}-${Math.random().toString(36).slice(2, 10)}`;

      const [allowed, info] = await redis.eval(
        SLIDING_WINDOW_SCRIPT,
        1,
        key,
        now,
        windowMs,
        max,
        member
      );

      res.setHeader('X-RateLimit-Limit', max);

      if (allowed === 1) {
        res.setHeader('X-RateLimit-Remaining', info);
        return next();
      }

      res.setHeader('X-RateLimit-Remaining', 0);
      res.setHeader('Retry-After', info);
      return res.status(429).json({
        error: 'Too Many Requests',
        retryAfter: info,
      });
    } catch (err) {
      // Fail-open strategy: do not block traffic if Redis is down
      console.error('Rate limiter error:', err.message);
      return next();
    }
  };
}

Step 3: Wire it into Express

// server.js
import express from 'express';
import { slidingWindowLimiter } from './rateLimiter.js';

const app = express();

// Trust proxy is essential behind Nginx, Cloudflare, or a load balancer
app.set('trust proxy', 1);

// Global limiter: 100 req/min per IP
app.use(slidingWindowLimiter({ windowMs: 60_000, max: 100 }));

// Stricter limiter for sensitive endpoints
const authLimiter = slidingWindowLimiter({
  windowMs: 15 * 60_000,
  max: 5,
  keyGenerator: (req) => `auth:${req.ip}`,
});

app.post('/login', authLimiter, (req, res) => {
  res.json({ ok: true });
});

app.get('/', (req, res) => res.json({ hello: 'world' }));

app.listen(3000, () => console.log('API on :3000'));
server traffic control

Edge Cases You Must Handle in Production

1. Trust the correct client IP

Behind a reverse proxy, req.ip will return the proxy IP unless you set app.set('trust proxy', N) where N is the number of hops. Get this wrong and you will rate limit your own load balancer, effectively banning everyone.

2. Authenticated vs anonymous users

Rate limiting by IP is fine for public traffic, but authenticated users deserve their own quota. Use a composite key:

keyGenerator: (req) => req.user?.id ? `user:${req.user.id}` : `ip:${req.ip}`

3. Fail-open vs fail-closed

What happens when Redis is unreachable? Two philosophies:

  • Fail-open: let requests through. Prioritizes availability. Recommended for most public APIs.
  • Fail-closed: block all requests. Use for financial or security-critical endpoints.

4. IPv6 subnets

A single IPv6 user can rotate through billions of addresses in a /64 block. If you only rate limit by full address, you are not really limiting anything. Normalize IPv6 to the /64 prefix before hashing.

5. Distributed setups and clock skew

Because our Lua script uses the timestamp passed by the Node process, servers with drifted clocks can misbehave. Either use Redis TIME command inside the script or make sure your fleet runs NTP.

Comparing With express-rate-limit

The popular express-rate-limit package is excellent for quick setups, especially combined with rate-limit-redis. Here is when to choose what:

Use case Recommended approach
Small app, single instance express-rate-limit with memory store
Multi-instance, moderate traffic express-rate-limit + rate-limit-redis (fixed window)
High-traffic, need fairness Custom sliding window (this tutorial)
Complex quotas, tiers, tokens rate-limiter-flexible

Testing Your Rate Limiter

A quick way to verify behavior is with a simple shell loop:

for i in {1..110}; do
  curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/
done | sort | uniq -c

You should see 100 responses with status 200 and 10 responses with 429.

For a more realistic load test, use autocannon:

npx autocannon -c 20 -d 30 http://localhost:3000/
server traffic control

Performance Considerations

Sorted sets in Redis have O(log N) writes. For most APIs this is negligible, but if a single key accumulates millions of entries per window, memory usage grows. Mitigations:

  1. Keep windows short (minutes, not hours).
  2. Set PEXPIRE on every write so idle keys are reclaimed.
  3. For very high throughput, switch to a sliding window counter variant that stores only two counters (current and previous window) and interpolates. Less accurate, but constant memory.

Security Hardening Checklist

  • Apply strict limits on /login, /register, /password-reset, and /2fa endpoints.
  • Combine rate limiting with a slow-down middleware for exponential backoff.
  • Return proper Retry-After headers so well-behaved clients back off.
  • Log 429 responses and alert on suspicious spikes.
  • Never expose your Redis instance to the public internet.

FAQ

Is express-rate-limit enough for production?

For many small to medium apps, yes, especially when paired with rate-limit-redis. If you need strict fairness, per-user quotas, or complex tiered limits, a custom sliding window or rate-limiter-flexible is a better choice. geeksforgeeks.org has a solid rundown on this.

Should I rate limit by IP or by user?

Both. Use IP-based limits for anonymous traffic and user-based limits for authenticated routes. This prevents shared-network false positives while still protecting against abuse.

What HTTP status code should I return?

Always return 429 Too Many Requests with a Retry-After header. This is the standard defined by RFC 6585 and is respected by browsers, SDKs, and monitoring tools.

Does the sliding window algorithm work across multiple servers?

Yes, as long as all Express instances share the same Redis. The Lua script ensures atomicity, so even under high concurrency you will not exceed the configured limit.

What if Redis goes down?

Decide upfront between fail-open and fail-closed. The middleware in this tutorial defaults to fail-open, which is safer for availability. For sensitive endpoints, override this behavior and return 503. This guide goes deeper on it.

Can I use this with serverless (AWS Lambda, Vercel)?

Yes, but connect to a managed Redis (Upstash, ElastiCache, Redis Cloud) and be mindful of connection pooling. Use a client that supports HTTP-based Redis calls if cold starts are an issue.

Wrapping Up

A robust rate limiting layer in Express.js is not just a plug-and-play middleware. It requires thinking about your traffic patterns, your topology, and your failure modes. The Redis-backed sliding window approach we built here gives you fair, accurate, and distributed limiting that scales with your API.

Start with sensible defaults, monitor your 429 rate, and iterate. Your infrastructure, and your on-call engineers, will thank you.