DEV Community

Alex Chen
Alex Chen

Posted on

Building a REST API Rate Limiter in Node.js (From Zero to Production)

Building a REST API Rate Limiter in Node.js (From Zero to Production)

Protect your API from abuse. A complete rate limiter implementation.

Why Rate Limit?

Without rate limiting:
  Attacker → 10,000 requests/second → Your server crashes 😵
  Bot → Scrapes all your data in seconds 🤖

With rate limiting:
  Legitimate user → 100 requests/min → ✅ Allowed
  Abusive client → 101st request → ❌ 429 Too Many Requests
Enter fullscreen mode Exit fullscreen mode

Approach 1: In-Memory (Simple, Single Server)

// Simple fixed-window rate limiter
class RateLimiter {
  constructor(options = {}) {
    this.windowMs = options.windowMs || 60_000; // 1 minute
    this.maxRequests = options.maxRequests || 100;
    this.store = new Map(); // IP → { count, resetTime }
  }

  middleware() {
    return (req, res, next) => {
      const key = req.ip || req.connection.remoteAddress;
      const now = Date.now();
      const record = this.store.get(key);

      if (!record || now > record.resetTime) {
        // New window
        const newRecord = { count: 1, resetTime: now + this.windowMs };
        this.store.set(key, newRecord);
        return this._addHeaders(res, newRecord, next);
      }

      if (record.count >= this.maxRequests) {
        const retryAfter = Math.ceil((record.resetTime - now) / 1000);
        return res.status(429).json({
          error: 'Too many requests',
          retryAfter,
        });
      }

      record.count++;
      return this._addHeaders(res, record, next);
    };
  }

  _addHeaders(res, record, next) {
    res.set('X-RateLimit-Limit', this.maxRequests);
    res.set('X-RateLimit-Remaining', Math.max(0, this.maxRequests - record.count));
    res.set('X-RateLimit-Reset', new Date(record.resetTime).toISOString());
    return next();
  }
}

// Usage
app.use(new RateLimiter({ windowMs: 60_000, maxRequests: 100 }).middleware());
Enter fullscreen mode Exit fullscreen mode

Approach 2: Sliding Window Log (More Accurate)

// Instead of "100 requests per minute" (fixed window),
// "no more than 100 requests in any 60-second rolling window"

class SlidingWindowLimiter {
  constructor(windowMs = 60_000, maxRequests = 100) {
    this.windowMs = windowMs;
    this.maxRequests = maxRequests;
    this.requests = new Map(); // IP → [timestamp, timestamp, ...]
  }

  middleware() {
    return (req, res, next) => {
      const key = req.ip;
      const now = Date.now();
      let timestamps = this.requests.get(key) || [];

      // Remove old entries outside the window
      timestamps = timestamps.filter(t => now - t < this.windowMs);

      if (timestamps.length >= this.maxRequests) {
        const oldestInWindow = timestamps[0];
        const retryAfter = Math.ceil(
          (this.windowMs - (now - oldestInWindow)) / 1000
        );
        return res.status(429).json({
          error: 'Too many requests',
          retryAfter,
        });
      }

      timestamps.push(now);
      this.requests.set(key, timestamps);

      // Cleanup old entries periodically
      if (Math.random() < 0.01) { // 1% chance per request
        this._cleanup();
      }

      next();
    };
  }

  _cleanup() {
    const now = Date.now();
    for (const [key, timestamps] of this.requests) {
      const filtered = timestamps.filter(t => now - t < this.windowMs);
      if (filtered.length === 0) {
        this.requests.delete(key);
      } else {
        this.requests.set(key, filtered);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Approach 3: Redis-Based (Production Ready, Multi-Server)

npm install ioredis
Enter fullscreen mode Exit fullscreen mode
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

class RedisRateLimiter {
  constructor(redisClient, options = {}) {
    this.redis = redisClient;
    this.windowMs = options.windowMs || 60_000;
    this.maxRequests = options.maxRequests || 100;
    this.prefix = options.prefix || 'ratelimit:';
  }

  async middleware() {
    return async (req, res, next) => {
      const key = this.prefix + (req.ip || req.connection.remoteAddress);
      const now = Date.now();
      const windowStart = now - this.windowMs;

      // Use sorted set for sliding window
      const pipeline = this.redis.pipeline();

      // Remove entries outside the window
      pipeline.zremrangebyscore(key, '-inf', windowStart);

      // Count remaining entries
      pipeline.zcard(key);

      // Add current request
      pipeline.zadd(key, now, `${now}-${Math.random()}`);

      // Set expiry on the key
      pipeline.pexpire(key, this.windowMs);

      const results = await pipeline.exec();
      const count = results[1][1]; // zcard result

      res.set('X-RateLimit-Limit', this.maxRequests);
      res.set('X-RateLimit-Remaining', Math.max(0, this.maxRequests - count));

      if (count > this.maxRequests) {
        // Find when the oldest entry expires
        const oldest = await this.redis.zrange(key, 0, 0, 'WITHSCORES');
        const retryAfter = Math.ceil(
          ((oldest[0][1] + this.windowMs) - now) / 1000
        );
        return res.status(429).json({
          error: 'Too many requests',
          retryAfter: Math.max(1, retryAfter),
        });
      }

      next();
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Using Express Rate Limit (Recommended for Most Apps)

npm install express-rate-limit
Enter fullscreen mode Exit fullscreen mode
const rateLimit = require('express-rate-limit');

// General API limiter
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                 // 100 requests per window
  message: { error: 'Too many requests, please try again later.' },
  standardHeaders: true,    // Return rate limit info in headers
  legacyHeaders: false,     // Disable X-RateLimit-* headers (use standard)
  keyGenerator: (req) => req.ip,
  handler: (req, res) => {
    res.status(429).json({ error: 'Too many requests' });
  },
});

// Apply to all routes
app.use('/api/', apiLimiter);

// Stricter limiter for auth endpoints
const authLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 5,                   // Only 5 login attempts per hour!
  message: { error: 'Too many login attempts. Try again later.' },
  skipSuccessfulRequests: true, // Don't count successful logins
});

app.post('/api/login', authLimiter, loginHandler);

// Very lenient for static assets
const staticLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 200,
});
app.use(staticLimiter);
Enter fullscreen mode Exit fullscreen mode

Advanced: Tiered Rate Limiting

// Different limits based on user tier
function createTieredLimiter(tiers) {
  return async (req, res, next) => {
    const user = req.user; // From auth middleware

    if (!user) {
      // Anonymous users — strictest limit
      return tiers.anonymous.middleware()(req, res, next);
    }

    const tierLimits = tiers[user.tier] || tiers.free;
    return tierLimits.middleware()(req, res, next);
  };
}

const limiter = createTieredLimiter({
  anonymous: rateLimit({ windowMs: 60_000, max: 20 }),
  free:       rateLimit({ windowMs: 60_000, max: 50 }),
  pro:        rateLimit({ windowMs: 60_000, max: 500 }),
  enterprise: rateLimit({ windowMs: 60_000, max: 5000 }),
});

app.use('/api/', authMiddleware, limiter);
Enter fullscreen mode Exit fullscreen mode

Response Headers Explained

HTTP/1.1 200 OK
X-RateLimit-Limit: 100         # Max requests allowed
X-RateLimit-Remaining: 97      # How many left this window
X-RateLimit-Reset: 1715841200  # Unix timestamp when window resets
Retry-After: 42               # Seconds until you can try again (on 429)
Enter fullscreen mode Exit fullscreen mode

How do you handle rate limiting? Any production war stories?

Follow @armorbreak for more Node.js content.

Top comments (0)