Application Security
rate limiting
api security
nodejs
express
+3 more

Rate Limiting APIs: The Complete Node.js & Express Implementation Guide

SCRs Team
March 25, 2026
14 min read
Share

Why Rate Limiting Is Your First Line of Defense

Every API without rate limiting is a DDoS target, a credential stuffing playground, and a cost explosion waiting to happen.

AttackWithout Rate LimitingWith Rate Limiting
Credential stuffing100K attempts/minute5 attempts/minute
API scrapingFull database exfiltrationPartial data, slowed
DDoSService downGraceful degradation
Brute force OTPCracked in secondsLocked after 3 tries
Cloud cost attack$50K surprise billCapped at normal usage

Strategy 1: Fixed Window Counter

Simplest approach — count requests per time window.

// Simple in-memory fixed window (single server only)
const windowMs = 60 * 1000; // 1 minute
const maxRequests = 100;
const clients = new Map<string, { count: number; resetAt: number }>();

function fixedWindowLimit(clientId: string): boolean {
  const now = Date.now();
  const client = clients.get(clientId);
  
  if (!client || now > client.resetAt) {
    clients.set(clientId, { count: 1, resetAt: now + windowMs });
    return true; // Allowed
  }
  
  if (client.count >= maxRequests) {
    return false; // Rate limited
  }
  
  client.count++;
  return true;
}

Flaw: Burst at window boundary. A client can send 100 requests at 0:59 and 100 more at 1:00 — 200 requests in 2 seconds.


Strategy 2: Sliding Window Log

Tracks exact timestamps of each request. More accurate, more memory.

function slidingWindowLog(clientId: string, window: number, max: number): boolean {
  const now = Date.now();
  const key = \`ratelimit:${clientId}\`;
  
  // Get request timestamps for this client
  let timestamps = requestLogs.get(key) || [];
  
  // Remove expired timestamps
  timestamps = timestamps.filter(t => t > now - window);
  
  if (timestamps.length >= max) {
    return false;
  }
  
  timestamps.push(now);
  requestLogs.set(key, timestamps);
  return true;
}

Strategy 3: Token Bucket (Best for APIs)

Allows bursts while maintaining average rate — ideal for API rate limiting.

class TokenBucket {
  private tokens: number;
  private lastRefill: number;
  
  constructor(
    private maxTokens: number,
    private refillRate: number, // tokens per second
  ) {
    this.tokens = maxTokens;
    this.lastRefill = Date.now();
  }
  
  consume(count: number = 1): boolean {
    this.refill();
    
    if (this.tokens >= count) {
      this.tokens -= count;
      return true;
    }
    return false;
  }
  
  private refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(
      this.maxTokens,
      this.tokens + elapsed * this.refillRate
    );
    this.lastRefill = now;
  }
}

Production Setup: Redis-Backed Sliding Window

For multi-server deployments, use Redis:

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

async function rateLimitRedis(
  identifier: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
  const key = \`rl:${identifier}\`;
  const now = Date.now();
  const windowStart = now - windowSeconds * 1000;
  
  const pipeline = redis.pipeline();
  
  // Remove old entries
  pipeline.zremrangebyscore(key, 0, windowStart);
  // Add current request
  pipeline.zadd(key, now.toString(), \`${now}-${Math.random()}\`);
  // Count requests in window
  pipeline.zcard(key);
  // Set expiry
  pipeline.expire(key, windowSeconds);
  
  const results = await pipeline.exec();
  const requestCount = results![2][1] as number;
  
  return {
    allowed: requestCount <= limit,
    remaining: Math.max(0, limit - requestCount),
    resetIn: windowSeconds,
  };
}

Express Middleware Implementation

import { Request, Response, NextFunction } from 'express';

interface RateLimitConfig {
  windowMs: number;
  max: number;
  keyGenerator?: (req: Request) => string;
  message?: string;
  skipSuccessfulRequests?: boolean;
}

function createRateLimiter(config: RateLimitConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = config.keyGenerator?.(req) 
      ?? req.ip 
      ?? req.headers['x-forwarded-for'] as string
      ?? 'unknown';
    
    const { allowed, remaining, resetIn } = await rateLimitRedis(
      key,
      config.max,
      config.windowMs / 1000
    );
    
    // Always set headers
    res.set({
      'X-RateLimit-Limit': config.max.toString(),
      'X-RateLimit-Remaining': remaining.toString(),
      'X-RateLimit-Reset': Math.ceil(Date.now() / 1000 + resetIn).toString(),
      'Retry-After': resetIn.toString(),
    });
    
    if (!allowed) {
      return res.status(429).json({
        error: config.message || 'Too many requests',
        retryAfter: resetIn,
      });
    }
    
    next();
  };
}

// Usage
app.use('/api/', createRateLimiter({ windowMs: 60000, max: 100 }));
app.use('/api/auth/login', createRateLimiter({ 
  windowMs: 900000, max: 5,
  keyGenerator: (req) => \`login:${req.body?.email || req.ip}\`,
  message: 'Too many login attempts. Try again in 15 minutes.',
}));
app.use('/api/auth/forgot-password', createRateLimiter({ windowMs: 3600000, max: 3 }));

Tiered Rate Limiting by Plan

const PLAN_LIMITS = {
  free:       { rpm: 20,   rpd: 1000 },
  starter:    { rpm: 100,  rpd: 10000 },
  pro:        { rpm: 500,  rpd: 100000 },
  enterprise: { rpm: 5000, rpd: 1000000 },
};

async function tieredRateLimit(req: Request, res: Response, next: NextFunction) {
  const apiKey = req.headers['x-api-key'] as string;
  const plan = await getPlanForKey(apiKey);
  const limits = PLAN_LIMITS[plan || 'free'];
  
  const [minuteResult, dayResult] = await Promise.all([
    rateLimitRedis(\`${apiKey}:min\`, limits.rpm, 60),
    rateLimitRedis(\`${apiKey}:day\`, limits.rpd, 86400),
  ]);
  
  if (!minuteResult.allowed || !dayResult.allowed) {
    return res.status(429).json({ error: 'Rate limit exceeded', plan });
  }
  
  next();
}

Common Mistakes

  1. Rate limiting by IP only — Shared IPs (offices, VPNs) block all users. Use API key + IP combo.
  2. Not rate limiting authenticated endpoints — Stolen tokens can scrape everything.
  3. Returning different errors for valid/invalid usernames — Enables enumeration even with rate limiting.
  4. Client-side rate limiting only — Trivially bypassed with curl.
  5. Forgetting WebSocket endpoints — Rate limit connection attempts and message frequency.

Advertisement