Nodejs

Rate Limiting Express.js APIs

A practical guide to rate limiting Express.js APIs covering in-memory and Redis-based limiters, sliding windows, per-user limits, API key tiers, and response headers.

Rate Limiting Express.js APIs

Rate limiting controls how many requests a client can make to your API within a time window. Without it, a single client — whether malicious or misconfigured — can overwhelm your server, degrade performance for everyone, and run up infrastructure costs.

Rate limiting protects against brute force attacks on login endpoints, denial-of-service from aggressive crawlers, runaway scripts that hammer your API in a loop, and accidental abuse from buggy client code. This guide covers implementing rate limiting in Express.js from basic in-memory solutions to production-ready Redis-backed limiters.

Prerequisites

  • Node.js installed (v16+)
  • An Express.js application
  • Redis (optional, for distributed rate limiting)

Basic Rate Limiting with express-rate-limit

The express-rate-limit package is the most widely used rate limiting middleware for Express:

npm install express-rate-limit

Simple Global Limiter

var rateLimit = require("express-rate-limit");

var limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                  // 100 requests per window
  standardHeaders: true,     // Return rate limit info in headers
  legacyHeaders: false,      // Disable X-RateLimit-* headers
  message: {
    error: {
      message: "Too many requests, please try again later",
      code: "RATE_LIMIT_EXCEEDED"
    }
  }
});

app.use(limiter);

This allows 100 requests per 15 minutes from each IP address. When the limit is exceeded, the client receives a 429 status code.

Per-Route Limiters

Different endpoints need different limits. Login attempts should be strictly limited while read-only endpoints can be more generous:

var loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: {
    error: {
      message: "Too many login attempts. Try again in 15 minutes.",
      code: "LOGIN_RATE_LIMIT"
    }
  }
});

var apiLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 60,
  message: {
    error: {
      message: "API rate limit exceeded",
      code: "API_RATE_LIMIT"
    }
  }
});

var searchLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 10,
  message: {
    error: {
      message: "Too many search requests",
      code: "SEARCH_RATE_LIMIT"
    }
  }
});

app.post("/auth/login", loginLimiter, loginHandler);
app.use("/api", apiLimiter);
app.get("/api/search", searchLimiter, searchHandler);

Response Headers

When standardHeaders is enabled, each response includes:

RateLimit-Limit: 100
RateLimit-Remaining: 87
RateLimit-Reset: 1707868800

These headers tell the client:

  • RateLimit-Limit — the maximum number of requests in the window
  • RateLimit-Remaining — how many requests remain
  • RateLimit-Reset — when the window resets (Unix timestamp)

When the limit is exceeded, the response also includes:

Retry-After: 900

This tells the client how many seconds to wait before retrying.

Custom Key Generation

By default, express-rate-limit identifies clients by IP address. Customize this for different use cases:

Per-User Rate Limiting

var userLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  keyGenerator: function(req) {
    // Use authenticated user ID if available, otherwise IP
    if (req.user && req.user.id) {
      return "user:" + req.user.id;
    }
    return req.ip;
  }
});

Per-API-Key Rate Limiting

var apiKeyLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: function(req) {
    // Different limits per API key tier
    if (req.apiClient && req.apiClient.tier === "premium") {
      return 1000;
    }
    if (req.apiClient && req.apiClient.tier === "standard") {
      return 100;
    }
    return 20; // Free tier
  },
  keyGenerator: function(req) {
    return req.headers["x-api-key"] || req.ip;
  }
});

Per-Endpoint Rate Limiting

var endpointLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 30,
  keyGenerator: function(req) {
    // Separate limits per endpoint per user
    return req.ip + ":" + req.method + ":" + req.path;
  }
});

Building a Custom Rate Limiter

For full control, build a rate limiter from scratch:

In-Memory Sliding Window

// middleware/rateLimiter.js
function createRateLimiter(options) {
  var windowMs = options.windowMs || 60000;
  var max = options.max || 100;
  var keyGenerator = options.keyGenerator || function(req) { return req.ip; };
  var requests = {};

  // Cleanup old entries every minute
  setInterval(function() {
    var now = Date.now();
    Object.keys(requests).forEach(function(key) {
      requests[key] = requests[key].filter(function(timestamp) {
        return now - timestamp < windowMs;
      });
      if (requests[key].length === 0) {
        delete requests[key];
      }
    });
  }, 60000);

  return function(req, res, next) {
    var key = keyGenerator(req);
    var now = Date.now();
    var maxRequests = typeof max === "function" ? max(req) : max;

    // Initialize or clean expired entries
    if (!requests[key]) {
      requests[key] = [];
    }

    requests[key] = requests[key].filter(function(timestamp) {
      return now - timestamp < windowMs;
    });

    // Set response headers
    var remaining = Math.max(0, maxRequests - requests[key].length);
    var resetTime = requests[key].length > 0
      ? new Date(requests[key][0] + windowMs)
      : new Date(now + windowMs);

    res.setHeader("RateLimit-Limit", maxRequests);
    res.setHeader("RateLimit-Remaining", remaining);
    res.setHeader("RateLimit-Reset", Math.ceil(resetTime.getTime() / 1000));

    // Check limit
    if (requests[key].length >= maxRequests) {
      var retryAfter = Math.ceil((requests[key][0] + windowMs - now) / 1000);
      res.setHeader("Retry-After", retryAfter);

      return res.status(429).json({
        error: {
          message: "Rate limit exceeded. Try again in " + retryAfter + " seconds.",
          code: "RATE_LIMIT_EXCEEDED",
          retryAfter: retryAfter
        }
      });
    }

    // Record the request
    requests[key].push(now);
    next();
  };
}

module.exports = createRateLimiter;
var rateLimiter = require("./middleware/rateLimiter");

app.use("/api", rateLimiter({
  windowMs: 60 * 1000,
  max: 60,
  keyGenerator: function(req) { return req.ip; }
}));

Fixed Window vs Sliding Window

Fixed window: Resets at fixed intervals (e.g., every minute on the minute). Simple but allows burst traffic at window boundaries — a client can make 100 requests at 12:00:59 and another 100 at 12:01:00.

Sliding window: Each request starts its own expiration timer. Prevents boundary bursts. The implementation above uses a sliding window by tracking individual request timestamps.

Redis-Based Rate Limiting

In-memory rate limiting does not work when your application runs on multiple servers. Each server has its own counter, so a client could send 100 requests to server A and another 100 to server B, bypassing the 100-request limit.

Redis provides a shared counter across all application instances.

Using express-rate-limit with Redis

npm install express-rate-limit rate-limit-redis redis
var rateLimit = require("express-rate-limit");
var RedisStore = require("rate-limit-redis").default;
var redis = require("redis");

var redisClient = redis.createClient({
  url: process.env.REDIS_URL
});
redisClient.connect();

var limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: function() {
      return redisClient.sendCommand(Array.prototype.slice.call(arguments));
    }
  })
});

app.use("/api", limiter);

Custom Redis Rate Limiter

// middleware/redisRateLimiter.js
var redis = require("redis");

var client = redis.createClient({ url: process.env.REDIS_URL });
client.connect();

function createRedisLimiter(options) {
  var windowMs = options.windowMs || 60000;
  var max = options.max || 100;
  var keyPrefix = options.keyPrefix || "rl:";
  var keyGenerator = options.keyGenerator || function(req) { return req.ip; };

  return function(req, res, next) {
    var key = keyPrefix + keyGenerator(req);
    var windowSeconds = Math.ceil(windowMs / 1000);
    var maxRequests = typeof max === "function" ? max(req) : max;

    client.multi()
      .incr(key)
      .expire(key, windowSeconds)
      .exec()
      .then(function(results) {
        var count = results[0];

        res.setHeader("RateLimit-Limit", maxRequests);
        res.setHeader("RateLimit-Remaining", Math.max(0, maxRequests - count));

        if (count > maxRequests) {
          return client.ttl(key).then(function(ttl) {
            res.setHeader("Retry-After", ttl);
            res.status(429).json({
              error: {
                message: "Rate limit exceeded",
                code: "RATE_LIMIT_EXCEEDED",
                retryAfter: ttl
              }
            });
          });
        }

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

module.exports = createRedisLimiter;

The multi() command runs incr and expire atomically. The counter increments, and the key automatically expires after the window. If Redis is unavailable, the limiter fails open (allows all requests) rather than blocking everything.

API Key Tier System

Implement different rate limits based on subscription tiers:

// middleware/tieredLimiter.js
var TIERS = {
  free: { requestsPerMinute: 20, requestsPerDay: 1000 },
  basic: { requestsPerMinute: 60, requestsPerDay: 10000 },
  pro: { requestsPerMinute: 300, requestsPerDay: 100000 },
  enterprise: { requestsPerMinute: 1000, requestsPerDay: 1000000 }
};

function tieredLimiter(redisClient) {
  return function(req, res, next) {
    var tier = (req.apiClient && req.apiClient.tier) || "free";
    var limits = TIERS[tier] || TIERS.free;
    var key = req.headers["x-api-key"] || req.ip;

    var minuteKey = "rl:min:" + key;
    var dayKey = "rl:day:" + key;

    Promise.all([
      redisClient.multi().incr(minuteKey).expire(minuteKey, 60).exec(),
      redisClient.multi().incr(dayKey).expire(dayKey, 86400).exec()
    ]).then(function(results) {
      var minuteCount = results[0][0];
      var dayCount = results[1][0];

      res.setHeader("X-RateLimit-Tier", tier);
      res.setHeader("RateLimit-Limit", limits.requestsPerMinute);
      res.setHeader("RateLimit-Remaining", Math.max(0, limits.requestsPerMinute - minuteCount));
      res.setHeader("X-RateLimit-Daily-Limit", limits.requestsPerDay);
      res.setHeader("X-RateLimit-Daily-Remaining", Math.max(0, limits.requestsPerDay - dayCount));

      if (minuteCount > limits.requestsPerMinute) {
        return res.status(429).json({
          error: {
            message: "Per-minute rate limit exceeded",
            code: "MINUTE_RATE_LIMIT",
            tier: tier,
            limit: limits.requestsPerMinute
          }
        });
      }

      if (dayCount > limits.requestsPerDay) {
        return res.status(429).json({
          error: {
            message: "Daily rate limit exceeded",
            code: "DAILY_RATE_LIMIT",
            tier: tier,
            limit: limits.requestsPerDay
          }
        });
      }

      next();
    }).catch(function(err) {
      console.error("Tiered rate limiter error:", err);
      next(); // Fail open
    });
  };
}

module.exports = tieredLimiter;

Specific Endpoint Protection

Login Brute Force Protection

var loginAttempts = {};

function loginProtection(req, res, next) {
  var key = req.ip + ":" + (req.body.email || "unknown");
  var now = Date.now();
  var windowMs = 15 * 60 * 1000; // 15 minutes
  var maxAttempts = 5;

  if (!loginAttempts[key]) {
    loginAttempts[key] = [];
  }

  // Remove old attempts
  loginAttempts[key] = loginAttempts[key].filter(function(t) {
    return now - t < windowMs;
  });

  if (loginAttempts[key].length >= maxAttempts) {
    var retryAfter = Math.ceil((loginAttempts[key][0] + windowMs - now) / 1000);
    return res.status(429).json({
      error: {
        message: "Too many login attempts. Try again in " + Math.ceil(retryAfter / 60) + " minutes.",
        code: "LOGIN_RATE_LIMIT",
        retryAfter: retryAfter
      }
    });
  }

  loginAttempts[key].push(now);
  next();
}

// Clean up periodically
setInterval(function() {
  var now = Date.now();
  Object.keys(loginAttempts).forEach(function(key) {
    loginAttempts[key] = loginAttempts[key].filter(function(t) {
      return now - t < 15 * 60 * 1000;
    });
    if (loginAttempts[key].length === 0) delete loginAttempts[key];
  });
}, 60000);

app.post("/auth/login", loginProtection, loginHandler);

Signup Rate Limiting

var signupLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3,                    // 3 signups per hour per IP
  keyGenerator: function(req) { return req.ip; },
  message: {
    error: {
      message: "Too many accounts created from this IP. Try again in an hour.",
      code: "SIGNUP_RATE_LIMIT"
    }
  }
});

app.post("/auth/register", signupLimiter, registerHandler);

Password Reset Protection

var passwordResetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3,                    // 3 reset requests per hour
  keyGenerator: function(req) {
    return "reset:" + (req.body.email || req.ip);
  },
  message: {
    error: {
      message: "Too many password reset requests",
      code: "RESET_RATE_LIMIT"
    }
  }
});

app.post("/auth/forgot-password", passwordResetLimiter, forgotPasswordHandler);

Handling Rate Limit Responses

Client-Side Retry Logic

function fetchWithRetry(url, options, maxRetries) {
  maxRetries = maxRetries || 3;

  return fetch(url, options).then(function(response) {
    if (response.status === 429 && maxRetries > 0) {
      var retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10);
      console.log("Rate limited. Retrying in " + retryAfter + " seconds...");

      return new Promise(function(resolve) {
        setTimeout(resolve, retryAfter * 1000);
      }).then(function() {
        return fetchWithRetry(url, options, maxRetries - 1);
      });
    }
    return response;
  });
}

Monitoring Rate Limits

// middleware/rateLimitMonitor.js
var rateLimitHits = {
  total: 0,
  byEndpoint: {},
  byIp: {}
};

function monitorRateLimits(req, res, next) {
  var originalJson = res.json;

  res.json = function(body) {
    if (res.statusCode === 429) {
      rateLimitHits.total++;

      var endpoint = req.method + " " + req.path;
      rateLimitHits.byEndpoint[endpoint] = (rateLimitHits.byEndpoint[endpoint] || 0) + 1;
      rateLimitHits.byIp[req.ip] = (rateLimitHits.byIp[req.ip] || 0) + 1;

      console.warn(JSON.stringify({
        level: "warn",
        type: "rate_limit_hit",
        ip: req.ip,
        method: req.method,
        path: req.path,
        timestamp: new Date().toISOString()
      }));
    }

    return originalJson.call(res, body);
  };

  next();
}

// Expose stats
function getRateLimitStats() {
  return Object.assign({}, rateLimitHits);
}

module.exports = {
  monitor: monitorRateLimits,
  getStats: getRateLimitStats
};

Common Issues and Troubleshooting

Rate limiter blocks all requests behind a proxy

All requests appear to come from the same IP (the proxy):

Fix: Set app.set("trust proxy", true) so Express reads the real client IP from X-Forwarded-For. Without this, all clients share one rate limit counter.

Rate limits reset on server restart

In-memory stores lose their data when the process restarts:

Fix: Use Redis as the rate limit store. Redis persists data across server restarts and works across multiple application instances.

Different error format from rate limiter

The rate limit error response does not match your API's error format:

Fix: Customize the message option or use a handler function to send a response in your standard error format.

Legitimate users get rate limited

The limit is too aggressive for normal usage patterns:

Fix: Analyze actual usage patterns before setting limits. Start generous and tighten based on data. Use per-user limits instead of per-IP for authenticated endpoints. Implement tiered limits for different client types.

Best Practices

  • Rate limit every public endpoint. Even endpoints that seem safe can be abused. Apply generous defaults globally and strict limits on sensitive endpoints.
  • Use per-user limits for authenticated APIs. IP-based limits penalize users behind shared IPs (offices, VPNs). Authenticated requests should be limited by user ID.
  • Include rate limit headers in every response. RateLimit-Remaining and Retry-After headers let clients self-regulate before hitting the limit.
  • Use Redis for multi-server deployments. In-memory rate limiting only works for single-server setups. Redis provides a shared counter across all instances.
  • Fail open when the rate limiter is unavailable. If Redis is down, allow requests through rather than blocking all traffic. Monitor rate limiter health.
  • Log rate limit hits for security monitoring. Track which IPs and endpoints are rate limited. Patterns may indicate attacks or misconfigured clients.
  • Set aggressive limits on authentication endpoints. Login, registration, and password reset are high-value targets. 5 login attempts per 15 minutes is reasonable.
  • Document your rate limits in the API documentation. Clients need to know the limits to implement proper retry logic and backoff strategies.

References

Powered by Contentful