How to Implement Rate Limiting in Node.js and Express: A Practical Guide

How to Implement Rate Limiting in Node.js and Express: A Practical Guide

by | Aug 10, 2026 | Uncategorized | 0 comments

If you run a public Node.js API, sooner or later someone (or some bot) will hammer it with requests. Whether it’s a brute force attempt on your login endpoint, a scraper going wild, or simply a buggy client stuck in a retry loop, unprotected endpoints are a liability. Rate limiting in Node.js is the first line of defense, and in this guide we walk through how to implement it properly for production, not just in a toy example.

We will cover IP-based limits, user-based limits, distributed rate limiting with Redis, and the gotchas most tutorials skip (proxies, load balancers, and shared state across instances).

Why Rate Limiting Matters

Rate limiting controls how many requests a client can send to your server in a given time window. Without it, you expose your app to:

  • Brute force attacks on login and password reset endpoints
  • Denial of Service (DoS) from a single abusive IP
  • Resource exhaustion (CPU, DB connections, third-party API quotas)
  • Unexpected cloud bills when your infrastructure autoscales under attack
server security shield code

Choosing the Right Approach

Before writing code, pick the strategy that fits your architecture:

Approach Best For Storage
express-rate-limit (in-memory) Single instance apps, prototypes Node process memory
express-rate-limit + Redis Multi-instance, load-balanced APIs Redis
rate-limiter-flexible Advanced use (token bucket, block duration) Redis, Memory, MongoDB
API Gateway (Nginx, Kong, AWS) Infra-level protection External

For most Node.js teams, express-rate-limit combined with Redis hits the sweet spot. That’s what we build below.

Step 1: Basic IP-Based Rate Limiting

Start by installing the package:

npm install express express-rate-limit

Then wire it into your Express app:

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

const app = express();

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                 // 100 requests per window per IP
  standardHeaders: true,    // send RateLimit-* headers
  legacyHeaders: false,     // disable X-RateLimit-* headers
  message: {
    error: 'Too many requests, please try again later.'
  }
});

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

app.get('/api/hello', (req, res) => res.json({ ok: true }));

app.listen(3000);

That’s it for a single instance. But if you deploy behind a load balancer or run multiple Node processes, this will fail (each instance keeps its own counter). Time to add Redis.

server security shield code

Step 2: Distributed Rate Limiting with Redis

Install the Redis store for express-rate-limit:

npm install rate-limit-redis ioredis

Configure the limiter to share state across instances:

const express = require('express');
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis').default;
const Redis = require('ioredis');

const redisClient = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: process.env.REDIS_PORT || 6379
});

const app = express();

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.call(...args)
  })
});

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

Now every Node instance shares the same counters. Scaling horizontally no longer breaks your limits.

Fixing IP Detection Behind a Proxy

If you sit behind Nginx, Cloudflare, or an AWS load balancer, req.ip will be the proxy’s IP, not the client’s. All your users would share a single counter. Fix it with:

app.set('trust proxy', 1); // trust first hop

Set the number of proxies carefully. Trusting all proxies (true) opens you to IP spoofing via the X-Forwarded-For header.

Step 3: User-Based Rate Limiting

IP limits are blunt. Authenticated users deserve their own quota, tied to the user ID rather than the network address. Use the keyGenerator option:

const userLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 60,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.call(...args)
  }),
  keyGenerator: (req) => {
    // Fall back to IP for unauthenticated requests
    return req.user?.id ? `user:${req.user.id}` : `ip:${req.ip}`;
  }
});

app.use('/api/', authenticate, userLimiter);

This gives you fine-grained control: authenticated users get their own counter, unauthenticated traffic still gets IP-limited.

Step 4: Stricter Limits on Sensitive Endpoints

Login and password reset endpoints need tighter limits. Chain a stricter limiter on top:

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // 5 attempts per 15 min per IP
  skipSuccessfulRequests: true, // only count failures
  store: new RedisStore({
    sendCommand: (...args) => redisClient.call(...args)
  }),
  message: { error: 'Too many failed attempts. Try again in 15 minutes.' }
});

app.post('/auth/login', loginLimiter, loginHandler);
app.post('/auth/reset-password', loginLimiter, resetHandler);

The skipSuccessfulRequests flag is key: it prevents locking out legitimate users while still blocking brute force attempts.

server security shield code

Step 5: Tiered Limits for Paid Users

If you sell API access, different plans should have different quotas. Build a dynamic limiter:

const tierLimits = {
  free:  { max: 60,   windowMs: 60 * 1000 },
  pro:   { max: 600,  windowMs: 60 * 1000 },
  enterprise: { max: 6000, windowMs: 60 * 1000 }
};

const tieredLimiter = rateLimit({
  windowMs: 60 * 1000,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.call(...args)
  }),
  keyGenerator: (req) => `user:${req.user.id}`,
  max: (req) => tierLimits[req.user.plan || 'free'].max
});

app.use('/api/', authenticate, tieredLimiter);

Step 6: Return Useful Headers to Clients

Good API citizens tell clients where they stand. With standardHeaders: true, express-rate-limit sends:

  • RateLimit-Limit: max requests allowed in the window
  • RateLimit-Remaining: how many are left
  • RateLimit-Reset: seconds until the counter resets

When the limit is hit, respond with HTTP 429 Too Many Requests (the library does this by default) and include a Retry-After header. Well-behaved clients will back off automatically.

Common Pitfalls to Avoid

  1. Not trusting the proxy correctly. Everyone gets the same limit or IP spoofing becomes trivial.
  2. In-memory store in a clustered app. Each Node worker keeps its own counter, so effective limits are multiplied by the number of workers.
  3. Rate limiting static assets. Apply limiters only to API routes, not to /public or /health.
  4. Blocking health checks. Whitelist your monitoring IPs with the skip option.
  5. Ignoring Redis failures. If Redis goes down, decide whether to fail open (allow all) or fail closed (block all). Wrap the store in a fallback.
server security shield code

When to Reach for rate-limiter-flexible

If you need advanced algorithms like token bucket, leaky bucket, block duration after abuse, or per-route dynamic points cost, look at rate-limiter-flexible. It’s more configurable but has a steeper learning curve. For 90% of REST APIs, express-rate-limit plus Redis is enough.

Testing Your Rate Limiter

A quick way to confirm everything works is with a shell loop:

for i in {1..110}; do
  curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/hello
done

You should see 200 for the first 100 requests and 429 after that. For load testing, tools like autocannon or k6 give you a more realistic picture.

FAQ

Does rate limiting replace a Web Application Firewall (WAF)?

No. Rate limiting is one layer. A WAF (Cloudflare, AWS WAF) blocks malicious patterns, bots, and known bad IPs before traffic even hits your Node.js server. Use both.

What’s the difference between express-rate-limit and rate-limiter-flexible?

express-rate-limit is simpler and Express-specific. rate-limiter-flexible is framework-agnostic, supports more algorithms (token bucket, leaky bucket), and offers block duration and consecutive failure tracking. Pick the first for simplicity, the second for complex scenarios.

Should I rate limit by IP or by user?

Both. IP limits protect unauthenticated endpoints (login, signup, public search). User limits enforce fair usage on authenticated endpoints. Combine them for full coverage.

What HTTP status code should I return?

Always use 429 Too Many Requests. Include a Retry-After header so clients know when to try again.

Will rate limiting slow down my API?

Minimally. In-memory stores add microseconds. Redis adds a network round trip (typically under 1ms if Redis is in the same region). The tradeoff is well worth it.

How do I handle Redis downtime?

Wrap the store with a try/catch fallback to in-memory limiting, or configure rate-limit-redis to fail open. Monitor Redis with alerts so you catch outages quickly.

Wrapping Up

Implementing rate limiting in Node.js is not just a checkbox for security audits. It protects your users, your servers, and your bill. Start with express-rate-limit for the basic case, move to Redis as soon as you scale beyond one instance, and layer stricter rules on sensitive endpoints. With the patterns above, your API is ready for the real world.

Need help architecting a production-grade Node.js backend? Box Software builds and hardens APIs for teams that can’t afford downtime. Get in touch and we’ll audit your setup.