Security Node.js API Design April 5, 2026

API Rate Limiting in Node.js: Complete Guide (2026)

Protect your API from abuse with token bucket, sliding window, and Redis-backed distributed rate limiting. Express and Fastify implementations.

Why Rate Limit?

Rate limiting prevents abuse, protects backend resources, ensures fair usage across clients, and stops runaway scripts from overwhelming your API. Without it, a single aggressive client can degrade service for everyone. Every production API needs rate limiting — the question is which algorithm and where to implement it.

Express: Basic Rate Limiting

The express-rate-limit package is the fastest way to add rate limiting to Express apps. It uses an in-memory store by default.

const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' },
  keyGenerator: (req) => req.headers['x-api-key'] || req.ip // Rate limit by API key first
});

app.use('/api/', apiLimiter);

Sliding Window Algorithm

Fixed windows have a burst problem — a client can make 100 requests at the end of one window and 100 at the start of the next (200 in seconds). Sliding window smooths this by weighting the previous window.

class SlidingWindowLimiter {
  constructor(windowMs, maxRequests) {
    this.windowMs = windowMs;
    this.maxRequests = maxRequests;
    this.windows = new Map(); // key -> { current, previous, currentStart }
  }

  isAllowed(key) {
    const now = Date.now();
    const windowStart = Math.floor(now / this.windowMs) * this.windowMs;
    
    if (!this.windows.has(key)) {
      this.windows.set(key, { current: 0, previous: 0, currentStart: windowStart });
    }
    
    const record = this.windows.get(key);
    
    // Rotate windows
    if (record.currentStart !== windowStart) {
      record.previous = record.currentStart === windowStart - this.windowMs ? record.current : 0;
      record.current = 0;
      record.currentStart = windowStart;
    }
    
    // Weighted count: previous window weight + current window count
    const elapsed = (now - windowStart) / this.windowMs;
    const weight = record.previous * (1 - elapsed) + record.current;
    
    if (weight >= this.maxRequests) return false;
    
    record.current++;
    return true;
  }
}

Redis: Distributed Rate Limiting

In-memory limiters fail with multiple server instances — each sees only its own traffic. Redis provides a shared counter across all instances.

const Redis = require('ioredis');
const redis = new Redis();

async function redisRateLimit(key, maxRequests, windowSec) {
  const luaScript = `
    local current = redis.call('INCR', KEYS[1])
    if current == 1 then
      redis.call('EXPIRE', KEYS[1], ARGV[1])
    end
    return current
  `;
  const count = await redis.eval(luaScript, 1, `rl:${key}`, windowSec);
  
  return {
    allowed: count <= maxRequests,
    remaining: Math.max(0, maxRequests - count),
    resetAt: Date.now() + windowSec * 1000
  };
}

// Express middleware
async function rateLimitMiddleware(req, res, next) {
  const key = req.headers['x-api-key'] || req.ip;
  const result = await redisRateLimit(key, 100, 900); // 100 per 15 min

  res.set('RateLimit-Limit', '100');
  res.set('RateLimit-Remaining', String(result.remaining));
  res.set('RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));

  if (!result.allowed) {
    return res.status(429).json({ error: 'Rate limit exceeded' });
  }
  next();
}

Tiered Rate Limits by Plan

SaaS APIs typically have different rate limits per pricing tier. Combine API key lookup with Redis rate limiting for per-plan enforcement.

const PLAN_LIMITS = {
  free:    { rpm: 10,  daily: 200  },
  starter: { rpm: 60,  daily: 5000 },
  pro:     { rpm: 300, daily: 50000 },
  business:{ rpm: 1000, daily: 500000 }
};

async function tieredRateLimit(req, res, next) {
  const apiKey = req.headers['x-api-key'];
  if (!apiKey) return res.status(401).json({ error: 'API key required' });

  const user = await getUserByApiKey(apiKey);
  if (!user) return res.status(401).json({ error: 'Invalid API key' });

  const limits = PLAN_LIMITS[user.plan] || PLAN_LIMITS.free;

  // Check per-minute limit
  const minuteResult = await redisRateLimit(`${apiKey}:min`, limits.rpm, 60);
  if (!minuteResult.allowed) {
    res.set('Retry-After', '60');
    return res.status(429).json({
      error: 'Rate limit exceeded',
      limit: limits.rpm,
      plan: user.plan,
      upgrade: 'https://snapapi.pics/dashboard.html#billing'
    });
  }

  // Check daily limit
  const dailyResult = await redisRateLimit(`${apiKey}:day`, limits.daily, 86400);
  if (!dailyResult.allowed) {
    return res.status(429).json({ error: 'Daily quota exceeded', limit: limits.daily });
  }

  req.user = user;
  req.rateLimit = { remaining: minuteResult.remaining, daily: dailyResult.remaining };
  next();
}

This is exactly how SnapAPI handles rate limiting — per-plan quotas with Redis-backed distributed counting across cluster instances. The RateLimit-* headers tell clients their current state without extra API calls.