Skip to content

Rate Limiting in Express

Rate limiting caps how many requests a client can make within a given time window, protecting against brute-force login attempts, scraping, and denial-of-service style abuse. This lesson covers implementing it with express-rate-limit.

How express-rate-limit Works

express-rate-limit tracks request counts per client (by IP address, by default) within a sliding time window. Once a client exceeds the configured maximum, subsequent requests receive a 429 Too Many Requests response until the window resets.

You can apply different limits to different parts of your app, a strict limit on /login to slow brute-force attempts, a looser one across the general API to prevent scraping and abuse.

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

const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                  // 100 requests per window per IP
  standardHeaders: true,
  legacyHeaders: false,
});

app.use(generalLimiter);

standardHeaders: true includes RateLimit-* response headers so well-behaved clients can see how close they are to the limit.

Stricter Limits for Sensitive Routes

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { error: 'Too many login attempts, try again later' },
});

app.post('/login', loginLimiter, loginHandler);
  • Apply a stricter, dedicated limiter to authentication and password-reset endpoints.
  • max is the maximum number of requests allowed per client within windowMs.
  • message customizes the response body sent once a client is rate limited.
  • For multi-server deployments, use a shared store (Redis) so limits apply across all instances.

Rate Limiting Cheatsheet

Typical rate limit configurations for different endpoint types.

Endpoint Type windowMs max
General API traffic 15 minutes 100
Login / authentication 15 minutes 5
Password reset requests 1 hour 3
Public search endpoint 1 minute 30

Using a Shared Store Across Multiple Instances

The default in-memory store tracks counts per server process, so a client could bypass the limit simply by hitting a different server instance behind a load balancer. A shared store like Redis ensures limits apply consistently across every instance.

const RedisStore = require('rate-limit-redis');
const { createClient } = require('redis');

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

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
});

Layering General and Route-Specific Limiters

A common setup applies a generous limiter globally, and a much stricter one on top for specific sensitive routes, both active at the same time.

app.use(generalLimiter);          // applies everywhere
app.use('/api/auth', authLimiter); // stricter, on top, only for auth routes

Common Mistakes

  • Setting a single global rate limit that's too loose to stop brute-force attacks on login but too strict for normal browsing.
  • Using the default in-memory store in a multi-instance deployment, letting clients bypass limits by hitting different servers.
  • Rate limiting purely by IP without considering shared IPs (corporate NATs, mobile carriers) causing false positives.
  • Not returning a clear 429 message, leaving legitimate users confused about why requests are failing.

Key Takeaways

  • express-rate-limit caps requests per client within a configurable time window.
  • Apply stricter limits to sensitive endpoints like login and password reset.
  • Use a shared store (Redis) for rate limiting to work correctly across multiple server instances.
  • Layer a general limiter with route-specific stricter limiters for defense in depth.

Pro Tip

Always rate-limit authentication endpoints far more strictly than the rest of your API, login and password-reset routes are the most common target for automated brute-force and credential-stuffing attacks.