Skip to content
Build an API Rate Limiter with Redis and Express (Step by Step)

Build an API Rate Limiter with Redis and Express (Step by Step)

DodaTech Updated Jun 20, 2026 8 min read

Build an API rate limiter using Redis with a sliding window counter and token bucket algorithm, implemented as Express middleware with configurable limits per user or IP and standard rate-limit HTTP headers.

What You’ll Build

You’ll build two rate-limiting algorithms — sliding window counter and token bucket — as Express middleware backed by Redis. The middleware returns standard X-RateLimit-* headers, supports per-user and per-IP limits, and gracefully handles over-limit requests with 429 responses. This same rate-limiting logic protects DodaTech APIs, including DodaZIP’s conversion endpoints and Durga Antivirus Pro signature updates.

Why Rate Limiters Matter

Every public API needs rate limiting. Without it, a single user can monopolize resources, brute-force authentication, or trigger a Denial-of-Service (DoS) condition. Rate limiting is a fundamental security and reliability pattern. Companies like GitHub, Stripe, and Twitter all enforce limits. Building your own teaches you Redis, middleware patterns, and algorithm design in one project.

Prerequisites

Step 1: Project Setup

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

Start Redis (if not running):

docker run -d -p 6379:6379 redis

Step 2: Redis Client Setup

// lib/redis.js
const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  retryStrategy: (times) => Math.min(times * 50, 2000),
});

redis.on('error', (err) => console.error('Redis error:', err));
redis.on('connect', () => console.log('Connected to Redis'));

module.exports = redis;

Step 3: Sliding Window Counter

The sliding window algorithm uses a Redis sorted set. Each request adds a member with the current timestamp as score. Expired members (outside the window) are removed. The count is the size of the set.

// lib/slidingWindow.js
const redis = require('./redis');

/**
 * Sliding window rate limiter
 * @param {number} windowMs - time window in milliseconds
 * @param {number} maxRequests - max requests allowed in the window
 */
function slidingWindow({ windowMs = 60000, maxRequests = 10 } = {}) {
  return async (req, res, next) => {
    const key = `ratelimit:sw:${req.ip}`;

    // If user-specific, use a header or token
    if (req.user?.id) {
      key = `ratelimit:sw:user:${req.user.id}`;
    }

    const now = Date.now();
    const windowStart = now - windowMs;

    try {
      // Remove entries outside the window
      await redis.zremrangebyscore(key, 0, windowStart);

      // Count remaining entries
      const current = await redis.zcard(key);

      // Add current request
      await redis.zadd(key, now, `${now}-${Math.random()}`);
      await redis.expire(key, Math.ceil(windowMs / 1000));

      const remaining = Math.max(0, maxRequests - current - 1);

      // Set rate limit headers
      res.set('X-RateLimit-Limit', maxRequests.toString());
      res.set('X-RateLimit-Remaining', remaining.toString());
      res.set('X-RateLimit-Reset', Math.ceil((now + windowMs) / 1000).toString());

      if (current >= maxRequests) {
        return res.status(429).json({
          error: 'Too many requests',
          message: `Rate limit exceeded. Try again in ${Math.ceil(windowMs / 1000)} seconds.`,
          retryAfter: Math.ceil(windowMs / 1000),
        });
      }

      next();
    } catch (err) {
      console.error('Rate limiter error:', err);
      next(); // Fail open: allow request if Redis is down
    }
  };
}

module.exports = slidingWindow;

Expected output: The first 10 requests in any 60-second window from the same IP succeed. The 11th returns HTTP 429 with a clear error message. Headers show the client exactly how many requests remain.

Step 4: Token Bucket Algorithm

The token bucket allows bursts up to the bucket size, then refills at a steady rate.

// lib/tokenBucket.js
const redis = require('./redis');

/**
 * Token bucket rate limiter using Lua script for atomicity
 * @param {number} capacity - max tokens (burst size)
 * @param {number} refillRate - tokens per second
 */
function tokenBucket({ capacity = 10, refillRate = 1 } = {}) {
  const luaScript = `
    local key = KEYS[1]
    local now = tonumber(ARGV[1])
    local capacity = tonumber(ARGV[2])
    local refillRate = tonumber(ARGV[3])
    local cost = tonumber(ARGV[4])

    local bucket = redis.call('HGETALL', key)
    local tokens
    local lastRefill

    if #bucket == 0 then
      tokens = capacity
      lastRefill = now
    else
      tokens = tonumber(bucket[2])
      lastRefill = tonumber(bucket[4])
    end

    -- Refill tokens based on elapsed time
    local elapsed = now - lastRefill
    local refill = math.floor(elapsed * refillRate / 1000)
    tokens = math.min(capacity, tokens + refill)

    if tokens >= cost then
      tokens = tokens - cost
      redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
      redis.call('EXPIRE', key, math.ceil(capacity / refillRate) + 1)
      return {1, tokens, math.ceil((capacity - tokens) / refillRate)}
    else
      local retryAfter = math.ceil((cost - tokens) / refillRate)
      return {0, tokens, retryAfter}
    end
  `;

  return async (req, res, next) => {
    const key = `ratelimit:tb:${req.ip}`;

    try {
      const result = await redis.eval(
        luaScript,
        1,
        key,
        Date.now(),
        capacity,
        refillRate,
        1  // cost per request
      );

      const [allowed, tokens, retryAfter] = result;

      res.set('X-RateLimit-Limit', capacity.toString());
      res.set('X-RateLimit-Remaining', Math.max(0, Math.floor(tokens)).toString());
      res.set('X-RateLimit-Reset', Math.ceil((Date.now() + retryAfter * 1000) / 1000).toString());

      if (!allowed) {
        return res.status(429).json({
          error: 'Too many requests',
          message: `Rate limit exceeded. Retry after ${retryAfter} seconds.`,
          retryAfter,
        });
      }

      next();
    } catch (err) {
      console.error('Token bucket error:', err);
      next();
    }
  };
}

module.exports = tokenBucket;

The Lua script ensures atomic operations — without it, two concurrent requests could both read the same token count and exceed the limit.

Step 5: Express Middleware Integration

// server.js
const express = require('express');
const slidingWindow = require('./lib/slidingWindow');
const tokenBucket = require('./lib/tokenBucket');

const app = express();

// Global rate limit: 60 requests per minute per IP
app.use('/api', slidingWindow({ windowMs: 60000, maxRequests: 60 }));

// Stricter limits for auth endpoints
app.use('/api/auth', slidingWindow({ windowMs: 60000, maxRequests: 5 }));

// Token bucket for burst-sensitive endpoints
app.use('/api/burst', tokenBucket({ capacity: 20, refillRate: 2 }));

app.get('/api/hello', (req, res) => {
  res.json({ message: 'Hello! You are rate limited.' });
});

app.post('/api/auth/login', (req, res) => {
  res.json({ message: 'Login endpoint — heavily rate limited' });
});

app.get('/api/burst/data', (req, res) => {
  res.json({ message: 'Burst endpoint — token bucket allows spikes' });
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(3000, () => {
  console.log('Rate limited API running on http://localhost:3000');
});

Step 6: Test the Rate Limiter

# First 5 auth requests succeed
for i in {1..6}; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/auth/login
done

Expected output:

200
200
200
200
200
429

Headers check:

curl -s -D - http://localhost:3000/api/hello | head -n 10

Expected output:

HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1687200000

Architecture


sequenceDiagram
    participant Client
    participant Express as Express Middleware
    participant Redis
    participant API as Route Handler

    Client->>Express: GET /api/resource
    Express->>Redis: ZREMRANGEBYSCORE (clean old)
    Express->>Redis: ZCARD (count requests)
    Express->>Redis: ZADD (add current)
    Express-->>Client: X-RateLimit headers

    alt Under limit
        Express->>API: next()
        API-->>Client: 200 OK + response
    else Over limit
        Express-->>Client: 429 Too Many Requests
    end

    Note over Express,Redis: Token bucket uses Lua EVAL for atomicity

Common Errors

1. Redis connection refused Redis is not running. Start with docker run -d -p 6379:6379 redis or install it natively. The retry strategy in lib/redis.js retries with backoff to handle temporary Redis restarts.

2. Rate limiter fires on every request (false positives) The sorted set key uses req.ip. If your app is behind a reverse proxy (NGINX, Cloudflare), req.ip is the proxy’s IP. Fix: use req.headers['x-forwarded-for'] || req.ip. For Express, set app.set('trust proxy', true).

3. Lua script execution errors Redis Lua scripts must return all values. If the script fails halfway (e.g., HMSET on a corrupted key), the entire script rolls back. Check Redis logs for script errors. The dead keys are automatically cleaned by EXPIRE.

4. Token bucket allows burst above capacity The Redis hash stores tokens and lastRefill. On first request, tokens = capacity. After refill, tokens = min(capacity, tokens + refill). If the EXPIRE is shorter than the refill time, a cold bucket starts at full capacity — which is correct behavior.

5. Headers not sent when middleware errors If Redis throws an error, the middleware logs it and calls next() (fail-open). Rate limit headers won’t be set for that request. This is intentional — the API stays available even if Redis is down. For strict enforcement, change next() to res.status(503).json(...).

Practice Questions

1. Why does the sliding window use a sorted set instead of a simple counter? A sorted set allows precise per-request tracking. Each member has a timestamp, so expired entries can be removed with ZREMRANGEBYSCORE. A counter (INCR) would need separate logic for window boundaries and couldn’t handle uneven request distributions.

2. What is the purpose of the Lua script in the token bucket? Redis Lua scripts execute atomically — no other commands run between HGETALL and HMSET. Without it, two concurrent requests could both see 5 tokens remaining and both proceed, exceeding the limit. Atomicity prevents race conditions.

3. Why does the middleware “fail open” on Redis errors? Fail-open means the request proceeds even if the rate limiter has an error. This keeps the API available during Redis outages. Fail-closed would block all requests when Redis is down — a worse outcome for most APIs.

4. Challenge: Rate limit by API key Extract x-api-key from the request header. Use it as part of the Redis key. Create a per-key limit. When an API key changes, reset the counter. This is how Stripe and GitHub implement per-token limits.

5. Challenge: Distributed rate limiting with sticky sessions In a multi-server setup, requests from the same user might hit different servers. Redis is shared, so the sorted set approach works globally. But ensure all servers use the same windowMs config. Test by running two Express instances behind a load balancer.

FAQ

What is the difference between sliding window and token bucket?
Sliding window limits requests in a rolling time window (last 60 seconds). Token bucket allows bursts up to a capacity, then refills at a steady rate. Sliding window is simpler and easier to explain. Token bucket is better for APIs with bursty traffic patterns.
Can I use this rate limiter with other Node.js frameworks?
Yes. Both middleware functions follow the standard (req, res, next) pattern and work with Koa (with koa-convert), Fastify (with @fastify/express), or plain Node.js HTTP servers.
How do I rate limit by user instead of IP?
Set the Redis key to include req.user.id instead of req.ip. The middleware checks req.user?.id before falling back to IP. Integrate with Passport.js or any authentication middleware that populates req.user.

Next Steps

  • Add Redis persistent storage for rate limit data
  • Explore the Express.js middleware ecosystem
  • Implement API key authentication with the JWT tutorial
  • Check the Rate Limiting system design deep-dive
  • Try the REST API project for more API patterns

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro