Rate Limiting in Node.js — Protect Your API from Abuse 2026

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why Rate Limiting Is Non-Negotiable

Without rate limiting, a single client can flood your API with thousands of requests per second, causing database overload, elevated costs, and denial of service for legitimate users. Rate limiting is the first line of defense against brute-force attacks, scrapers, and DDoS amplification.

Modern backends need per-IP, per-user, and per-endpoint limiting — not a single global limit.

express-rate-limit — Quick Setup

npm install express-rate-limit
npm install rate-limit-redis ioredis   # for Redis-backed distributed limiting
import rateLimit from 'express-rate-limit';
 
// Global limit — 100 requests per 15 minutes per IP
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit:    100,
  standardHeaders: 'draft-7',  // Return RateLimit headers
  legacyHeaders:   false,
  message: { error: 'Too many requests. Try again later.' },
});
 
app.use(globalLimiter);

Redis-Backed Distributed Rate Limiting

In-memory limiters reset when the process restarts and do not share state across multiple server instances. Use Redis for distributed environments:

import { RedisStore } from 'rate-limit-redis';
import { Redis } from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL!);
 
const distributedLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit:    100,
  standardHeaders: 'draft-7',
  legacyHeaders:   false,
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args) as any,
    prefix: 'rl:global:',
  }),
});

Per-Route Limits

Different routes deserve different limits. Login endpoints need tight limits; public read endpoints can be more permissive:

// Strict limit for auth endpoints — prevent brute-force
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit:    10,
  message:  { error: 'Too many login attempts. Try again in 15 minutes.' },
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args) as any,
    prefix: 'rl:auth:',
  }),
});
 
// API limit keyed by user ID for authenticated routes
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit:    60,
  keyGenerator: (req) => (req as any).user?.id ?? req.ip,  // per-user
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args) as any,
    prefix: 'rl:api:',
  }),
});
 
app.post('/login',    authLimiter, loginHandler);
app.post('/register', authLimiter, registerHandler);
app.use('/api',       apiLimiter);

Sliding Window with Redis Sorted Sets

For true sliding window semantics (no boundary burst), implement directly with Redis:

import { Redis } from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL!);
 
async function slidingWindowLimit(
  key: string,
  limit: number,
  windowMs: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
  const now      = Date.now();
  const windowStart = now - windowMs;
  const redisKey    = `rl:sw:${key}`;
 
  // Lua script for atomic prune + count + add
  const script = `
    local key   = KEYS[1]
    local now   = tonumber(ARGV[1])
    local start = tonumber(ARGV[2])
    local limit = tonumber(ARGV[3])
    local ttl   = tonumber(ARGV[4])
 
    redis.call('ZREMRANGEBYSCORE', key, '-inf', start)
    local count = redis.call('ZCARD', key)
    if count < limit then
      redis.call('ZADD', key, now, now .. math.random())
      redis.call('PEXPIRE', key, ttl)
      return {1, limit - count - 1}
    else
      return {0, 0}
    end
  `;
 
  const result = await redis.eval(
    script, 1, redisKey,
    now.toString(), windowStart.toString(),
    limit.toString(), windowMs.toString()
  ) as [number, number];
 
  return {
    allowed:   result[0] === 1,
    remaining: result[1],
    resetAt:   now + windowMs,
  };
}
 
// Middleware using sliding window
export function slidingWindowMiddleware(limit: number, windowMs: number) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = req.ip ?? 'unknown';
    const result = await slidingWindowLimit(key, limit, windowMs);
 
    res.setHeader('X-RateLimit-Limit',     limit);
    res.setHeader('X-RateLimit-Remaining', result.remaining);
    res.setHeader('X-RateLimit-Reset',     Math.ceil(result.resetAt / 1000));
 
    if (!result.allowed) {
      return res.status(429).json({ error: 'Rate limit exceeded' });
    }
    next();
  };
}

Token Bucket Algorithm

Token bucket allows short bursts above the average rate, making it feel more natural to users:

async function tokenBucket(
  key: string,
  capacity: number,
  refillRate: number,   // tokens per second
  cost = 1
): Promise&lt;boolean&gt; {
  const now  = Date.now() / 1000;
  const data = await redis.hgetall(`tb:${key}`);
 
  let tokens    = parseFloat(data.tokens ?? String(capacity));
  let lastRefill = parseFloat(data.lastRefill ?? String(now));
 
  // Refill tokens based on elapsed time
  const elapsed = now - lastRefill;
  tokens = Math.min(capacity, tokens + elapsed * refillRate);
 
  if (tokens &lt; cost) {
    await redis.hset(`tb:${key}`, 'tokens', tokens, 'lastRefill', now);
    await redis.expire(`tb:${key}`, 3600);
    return false;
  }
 
  await redis.hset(`tb:${key}`, 'tokens', tokens - cost, 'lastRefill', now);
  await redis.expire(`tb:${key}`, 3600);
  return true;
}

Skip and Custom Key Strategies

const smartLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit:    100,
  skip: (req) => {
    // Skip rate limiting for internal health checks
    return req.path === '/health' || req.ip === '127.0.0.1';
  },
  keyGenerator: (req) => {
    // Prefer user ID over IP for authenticated requests
    const user = (req as any).user;
    return user ? `user:${user.id}` : req.ip ?? 'unknown';
  },
});

Common Mistakes

  • Using in-memory stores in multi-instance deployments — each instance has its own counter, allowing N-times the limit
  • Applying the same limit to all routes — login endpoints need far tighter limits than public read APIs
  • Not returning Retry-After or X-RateLimit-Reset headers — clients cannot back off intelligently
  • Keying only on IP — shared NAT (offices, mobile carriers) punishes all users behind one IP
  • Using fixed window without awareness of boundary bursts — a user gets 2x the limit by straddling a window boundary

Best Practices

  • Use Redis for all production rate limiters — in-memory does not survive restarts or scale horizontally
  • Apply strict limits (5-10 req/15min) on /login, /register, /password-reset to block brute-force
  • Key authenticated routes by user ID rather than IP to be fair across shared networks
  • Always set standardHeaders: 'draft-7' to return standard RateLimit-* headers for client use
  • Log rate-limit hits with IP and path for security monitoring
  • Use sliding window for accuracy; token bucket for burst-tolerant APIs like file uploads

Key Takeaways

  • Rate limiting is the primary defense against brute-force, credential stuffing, and DDoS attacks
  • express-rate-limit with rate-limit-redis provides distributed limiting across all server instances
  • Sliding window counters eliminate the boundary-burst problem of fixed windows
  • Token bucket allows controlled bursts above the average rate — good for upload/download APIs
  • Key by user ID (not just IP) for authenticated endpoints to be fair across shared IPs
  • Always return RateLimit-Limit, RateLimit-Remaining, and Retry-After headers
  • Apply per-route limits: tighter on auth endpoints, looser on public read endpoints
  • Lua scripts in Redis ensure atomic increment-and-expire operations, preventing race conditions

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading